canvas 基础系列(二)之实现大转盘抽奖

上一章讲解了如何使用 canvas 实现刮刮卡抽奖,以及 canvas 最基本最基本的一些 api 方法。点击回顾
本章开始一步一步带着读者实现大转盘抽奖;大转盘是个很是简单且实用的 web 特效,五脏俱全,其中涉及到的知识点有 圆的绘制及非零环绕原则路径的绘制canvas transform逐帧动画 requestAnimationFrame 方法;接下来带你们一步一步的实现。javascript

项目预览连接地址css

扫描二维码预览

先贴出代码,读者能够复制如下代码,直接运行。
在代码后面我会逐一解释每一块关键代码的做用。
示例代码版本为 ES6 ,请在现代浏览器下运行如下代码html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>大转盘</title>
</head>
<body>
    <div id="spin_button" style="position: absolute;left: 232px;top: 232px;width: 50px;height: 50px;line-height: 50px;text-align: center;background: yellow;border-radius: 100%;cursor: pointer">旋转</div>
    <canvas id="canvas" width="500" height="500"></canvas>
</body>
<script> let canvas = document.getElementById('canvas'), context = canvas.getContext('2d'), OUTSIDE_RADIUAS = 200, // 转盘的半径 INSIDE_RADIUAS = 0, // 用于非零环绕原则的内圆半径 TEXT_RADIUAS = 160, // 转盘内文字的半径 CENTER_X = canvas.width / 2, CENTER_Y = canvas.height / 2, awards = [ // 转盘内的奖品个数以及内容 '大保健', '话费10元', '话费20元', '话费30元', '保时捷911', '周大福土豪金项链', 'iphone 20', '火星7日游' ], startRadian = 0, // 绘制奖项的起始角,改变该值实现旋转效果 awardRadian = (Math.PI * 2) / awards.length, // 每个奖项所占的弧度 duration = 4000, // 旋转事件 velocity = 10, // 旋转速率 spinningTime = 0, // 旋转当前时间 spinTotalTime, // 旋转时间总长 spinningChange; // 旋转变化值的峰值 /** * 缓动函数,由快到慢 * @param {Num} t 当前时间 * @param {Num} b 初始值 * @param {Num} c 变化值 * @param {Num} d 持续时间 */ function easeOut(t, b, c, d) { if ((t /= d / 2) < 1) return c / 2 * t * t + b; return -c / 2 * ((--t) * (t - 2) - 1) + b; }; /** * 绘制转盘 */ function drawRouletteWheel() { // ----- ① 清空页面元素,用于逐帧动画 context.clearRect(0, 0, canvas.width, canvas.height); // ----- for (let i = 0; i < awards.length; i ++) { let _startRadian = startRadian + awardRadian * i, // 每个奖项所占的起始弧度 _endRadian = _startRadian + awardRadian; // 每个奖项的终止弧度 // ----- ② 使用非零环绕原则,绘制圆盘 context.save(); if (i % 2 === 0) context.fillStyle = '#FF6766' else context.fillStyle = '#FD5757'; context.beginPath(); context.arc(canvas.width / 2, canvas.height / 2, OUTSIDE_RADIUAS, _startRadian, _endRadian, false); context.arc(canvas.width / 2, canvas.height / 2, INSIDE_RADIUAS, _endRadian, _startRadian, true); context.fill(); context.restore(); // ----- // ----- ③ 绘制文字 context.save(); context.font = 'bold 16px Helvetica, Arial'; context.fillStyle = '#FFF'; context.translate( CENTER_X + Math.cos(_startRadian + awardRadian / 2) * TEXT_RADIUAS, CENTER_Y + Math.sin(_startRadian + awardRadian / 2) * TEXT_RADIUAS ); context.rotate(_startRadian + awardRadian / 2 + Math.PI / 2); context.fillText(awards[i], -context.measureText(awards[i]).width / 2, 0); context.restore(); // ----- } // ----- ④ 绘制指针 context.save(); context.beginPath(); context.moveTo(CENTER_X, CENTER_Y - OUTSIDE_RADIUAS + 8); context.lineTo(CENTER_X - 10, CENTER_Y - OUTSIDE_RADIUAS); context.lineTo(CENTER_X - 4, CENTER_Y - OUTSIDE_RADIUAS); context.lineTo(CENTER_X - 4, CENTER_Y - OUTSIDE_RADIUAS - 10); context.lineTo(CENTER_X + 4, CENTER_Y - OUTSIDE_RADIUAS - 10); context.lineTo(CENTER_X + 4, CENTER_Y - OUTSIDE_RADIUAS); context.lineTo(CENTER_X + 10, CENTER_Y - OUTSIDE_RADIUAS); context.closePath(); context.fill(); context.restore(); // ----- } /** * 开始旋转 */ function rotateWheel() { // 当 当前时间 大于 总时间,中止旋转,并返回当前值 spinningTime += 20; if (spinningTime >= spinTotalTime) { console.log(getValue()); return } let _spinningChange = (spinningChange - easeOut(spinningTime, 0, spinningChange, spinTotalTime)) * (Math.PI / 180); startRadian += _spinningChange drawRouletteWheel(); window.requestAnimationFrame(rotateWheel); } /** * 旋转结束,获取值 */ function getValue() { let startAngle = startRadian * 180 / Math.PI, // 弧度转换为角度 awardAngle = awardRadian * 180 / Math.PI, pointerAngle = 90, // 指针所指向区域的度数,该值控制选取哪一个角度的值 overAngle = (startAngle + pointerAngle) % 360, // 不管转盘旋转了多少圈,产生了多大的任意角,咱们只须要求到当前位置起始角在360°范围内的角度值 restAngle = 360 - overAngle, // 360°减去已旋转的角度值,就是剩下的角度值 index = Math.floor(restAngle / awardAngle); // 剩下的角度值 除以 每个奖品的角度值,就能获得这是第几个奖品 return awards[index]; } window.onload = function(e) { drawRouletteWheel(); } document.getElementById('spin_button').addEventListener('click', () => { spinningTime = 0; // 初始化当前时间 spinTotalTime = Math.random() * 3 + duration; // 随机定义一个时间总量 spinningChange = Math.random() * 10 + velocity; // 随机顶一个旋转速率 rotateWheel(); }) </script>
</html>
复制代码

🚶思路:

  1. 当页面加载时会执行 drawRouletteWheel()方法,这个方法将经过starRadian, awardRadian, awards等全局变量,完成转盘的全部绘制操做,包括:圆盘,奖品选块,指针;java

  2. 定义点击事件,当点击旋转按钮,执行rotateWheel() 方法,该方法将动态改变全局变量 starRadian的值,并调用 window.requestAnimationFrame()方法实现逐帧旋转动画。git


绘制大转盘

咱们进入 drawRouletteWheel()方法,能够看到,该方法分为四步:github

  1. 清空页面中全部的元素;
  2. 绘制圆盘
  3. 绘制文字
  4. 绘制指针

  • 清空页面全部元素

之因此在绘制最开始对画布作清理,是为了完成逐帧动画。 咱们能够想象一下。你们都知道,咱们能够在不少页纸上画一个小人不一样的行走状态,而后经过快速翻阅这些纸张,小人就会神奇的‘动’起来,你翻的越快,小人就跑的越快。 在 canvas 中,或者说在 js 中实现动画,一样是这个道理,咱们就想像每一页纸就是动画里的每一帧,咱们翻页的操做,在电脑屏幕上,实际就是清空整个画布了。web


  • 绘制圆盘

咱们经过全局变量 awards 这个数组,指定了奖项的显示文字; 经过全局变量 startRadian 指定了起始角的弧度,也就是 0°; 经过 awardRadian 指定了每个奖品选快所占的弧度;该值是经过 360° 的弧度值除以 奖品 的个数计算来的。canvas


咱们知道了圆的起始角,以及每个奖品选块所占的弧度值,那么咱们是否是就能够经过循环 awards 数组的个数,来获取每个奖品选块的起始角,以及终止角,并绘制出每一个奖品选块的路径,将他们链接起来,就成了一个“大卸八块”的圆盘了呢?api


for (let i = 0; i < awards.length; i++) {
	let _startRadian = startRadian + awardRadian * i,  // 每个奖项所占的起始弧度
      _endRadian =   _startRadian + awardRadian;     // 每个奖项的终止弧度
	context.save();
	if (i % 2 === 0) context.fillStyle = '#FF6766'
	else             context.fillStyle = '#FD5757';
	context.beginPath();
	context.arc(canvas.width / 2, canvas.height / 2, OUTSIDE_RADIUAS, _startRadian, _endRadian, false);
	context.fill();
  context.restore()
}
复制代码

以上代码执行后,你会发现是这个鬼样子👻数组

图1

之因此会被渲染成这样,是由于咱们绘制了与奖品个数相同的圆弧,但这些圆弧之间彼此是没有联系的,他们是一个个单独的路径,因此填充时,也只会填充路径一端到另外一端区间内的空间。


为解决这个问题,咱们须要引入一个新的概念 非零环绕原则


什么是非零环绕原则? 这篇文章讲解的很是详细,你们能够详细参阅,总结一下,就是: 路径中指定范围区域,从该区域内部画一条足够长的线段,使此线段的彻底落在路径范围以外。 该线段与逆时针路径相交,计数器减1; 该线段与顺时针路径相交,计数器加1; 若是计数器的值不等于0,则该范围区域会被填充; 若是计数器的值等于0,则该范围区域不会被填充显示;

图2

了解了非零环绕原则,咱们将其实际运用,来解决咱们刚才的问题


咱们在上述代码中,建立的是若干个顺时针圆弧路径,那么咱们想让这些区块独自填充,是否是只要在圆内,再建立若干个半径为0,逆时针圆弧路径呢?

for (let i = 0; i < awards.length; i++) {
	let _startRadian = startRadian + awardRadian * i,  // 每个奖项所占的起始弧度
        _endRadian =   _startRadian + awardRadian;     // 每个奖项的终止弧度
	context.save();
	if (i % 2 === 0) context.fillStyle = '#FF6766'
	else             context.fillStyle = '#FD5757';
	context.beginPath();

	context.arc(canvas.width / 2, canvas.height / 2, OUTSIDE_RADIUAS, _startRadian, _endRadian, false);

	context.arc(canvas.width / 2, canvas.height/  2, OUTSIDE_RADIUAS, _endRadian, _startRadian, true);

	context.fill();
    context.restore()
}
复制代码

如图3所示,圆盘的绘制便完成了。

图3


  • 绘制文字

咱们须要在每个选块中,绘制相对应的文字,而且这些文字的角度与位置必须与圆弧一致。
这里咱们须要用到 三角函数 来求圆周上某点的坐标来做为文字的坐标,用 canvas transform 来对文字进行位移与旋转。


使用三角函数获取文字的坐标位置

在源码中有一段代码以下:

context.translate(
                CENTER_X + Math.cos(_startRadian + awardRadian / 2) * TEXT_RADIUAS,
                CENTER_Y + Math.sin(_startRadian + awardRadian / 2) * TEXT_RADIUAS
            );
复制代码

这段代码代码的意思是将元素移动到指定的x, y 轴位置。x, y 轴的计算公式看着复杂,但你只要有一点点三角函数的概念,就能很快理解它们是如何得出的。

如图4所示,

图4

若是咱们想要获取该图中圆周上的一个坐标相对 canvas 画布的位置,咱们须要将该点与圆心相链接,并从该点向下延伸与圆心的 x 轴相交后造成的一个直角三角形,并求出该直角的 a 与 b 两条边的长度,与圆心的 x y 轴坐标值相加,就是该点相对 canvas 画布 x y 轴的坐标。

那么如何获得 a b 两条边的长度? 咱们已知的条件有:center_x/center_y, radius, θ; 咱们知道,正弦 sin 是三角形的 对边比斜边,正好 b 是对边 余弦 cos 是三角形的 邻边比斜边,正好 a 是邻边; 那么 b = Math.sin(θ) * radiusa = Math.cos(θ) * radius

咱们能够经过三角函数的公式,获得每个奖品选块,中间位置的圆周上的坐标点,并使用 context.translate(x, y) 将文字元素移动到该点上;

将文字移动到中心点后,再经过 context.rotate(deg) 方法,将文字旋转角度与圆弧度对齐;

canvas 的 transform 中的方法,使用上基本和 css 是同样的,只不过 canvas 变换是相对于画布的变换。若是不太理解,能够参考这篇文章


  • 绘制指针 指针的绘制很是简单,其中涉及到三个新方法:

context.moveTo(x,y):创建路径的起点; context.lineTo(x,y): 创建一个点,该点与其余点以及起点相链接,造成一条路径; context.closePath(): 将路径最后一个点,与起点相链接,闭合路径。

了解了这三个方法,剩下的就是计算点位,再绘制一个本身喜欢的指针样式了。


旋转大转盘

  1. 点击旋转按钮,初始化当前时间,并随机指定一个旋转时间总长,和随机指定一个旋转变化值的峰值,最后调用 rotateWheel() 方法,开启旋转;

  2. rotatWheel() 方法里,咱们会将表明当前进行时间的变量 spinningTime 累加,直到其大于时间总长 spinTotalTime 后,便获取当前奖品值,并退出旋转;

  3. 咱们会利用缓动函数 easeOut() 来获取一个动态的缓动值,将这个值赋值给 startRadian 全局变量,并执行 drawRouletteWheel() 方法重绘转盘,便实现了旋转。


  • setInterval setTimeout 实现的简单动画

咱们一般使用 js 中的 setInterval() 或者 setTimeout() 方法,来实现动画,就像下面这样:

图5

let canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

let [x, y] = [0, 0],
    movingTime = 0,
    moveTotalTime = 3000;

function drawRect(x, y) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    context.rect(x, y, 100, 100);
    context.fill();
}

setInterval(() => {
    movingTime += 20;
    if (movingTime >= moveTotalTime) return;

    x += 1;
    drawRect(x, y)
}, 20)
复制代码

可是这两个方法并不能提供制做动画所需精确计时机制。它们只是让应用程序能在某个大体时间点上运行代码的通用方法而已。

咱们不该当主动去告知浏览器绘制下一帧动画的时间,而是应当让浏览器在它以为能够绘制下一帧时通知你,咱们能够用 window.requestAnimationFrame() 方法来实现。


  • window.requestAnimationFrame()

该方法接收一个回调函数参数,并返回一个句柄,咱们能够经过 window.cancleRequestAnimationFrame() 方法,指定一个句柄,来取消动画。

下面咱们将使用 setInterval() 方法实现的动画,改形成 window.requestAnimationFrame() 实现:

let canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

let [x, y] = [0, 0],
    movingTime = 0,
    moveTotalTime = 3000;

function drawRect(x, y) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    context.rect(x, y, 100, 100);
    context.fill();
}

function moveRect() {
    movingTime += 20;
    if (movingTime >= moveTotalTime) return;

    x += 1;
    drawRect(x, y);

    window.requestAnimationFrame(moveRect);
}

moveRect();
复制代码

很简单对吧!

可是咱们发现,这个方块移动的很僵硬,咱们须要加入缓动函数,来让它“灵活”起来。


  • 缓动函数

本章中只使用了一种缓动函数,easeOut() ,如今咱们不须要知道它是什么原理,只要知道如何使用它就好了:

/** * 缓动函数,由快到慢 * @param {Num} t 当前时间 * @param {Num} b 初始值 * @param {Num} c 变化值 * @param {Num} d 持续时间 */
function easeOut(t, b, c, d) {
    if ((t /= d / 2) < 1) return c / 2 * t * t + b;
    return -c / 2 * ((--t) * (t - 2) - 1) + b;
};
复制代码

该缓动函数会在单位时间内,从初始值,增长到变化值(峰值);

仍是拿刚才移动的小方块举例,缓动函数接收四个值,

  1. 当前时间,也就是 movingTime
  2. 初始值,通常设置为 0 ;
  3. 变化值(峰值) ,也就是 moveChange
  4. 持续时间,也就是 moveTotalTime

代码咱们就这么写:

let canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

let [x, y] = [0, 0],
    moveChange = 5,
    movingTime = 0,
    moveTotalTime = 3000;

function easeOut(t, b, c, d) {
    if ((t /= d / 2) < 1) return c / 2 * t * t + b;
    return -c / 2 * ((--t) * (t - 2) - 1) + b;
};

function drawRect(x, y) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    context.rect(x, y, 100, 100);
    context.fill();
}

function moveRect() {
    movingTime += 20;
    if (movingTime >= moveTotalTime) return;

    let _moveChange = moveChange - (easeOut(movingTime, 0, moveChange, moveTotalTime));

    x += _moveChange;
    drawRect(x, y);

    window.requestAnimationFrame(moveRect);
}

moveRect();
复制代码

效果如图6所示,

图6

  • 让转盘转起来 若是你理解了上面所讲的小方块的位移动画,那么大转盘的动画也是同样同样的!

惟一的区别就是须要在最后将变化值转换为弧度值,而且中止旋转时采集奖品的信息而已。


旋转结束,采集奖品信息

rotateWheel() 方法中,当 当前时间 大于 时间总量 时,会中止旋转,并触发 getValue() 方法。

function getValue() {
    let startAngle = startRadian * 180 / Math.PI,       // 弧度转换为角度
        awardAngle = awardRadian * 180 / Math.PI,

        pointerAngle = 90,                              // 指针所指向区域的度数,该值控制选取哪一个角度的值
        overAngle = (startAngle + pointerAngle) % 360,  // 不管转盘旋转了多少圈,产生了多大的任意角,咱们只须要求到当前位置起始角在360°范围内的角度值
        restAngle = 360 - overAngle,                    // 360°减去已旋转的角度值,就是剩下的角度值

        index = Math.floor(restAngle / awardAngle);     // 剩下的角度值 除以 每个奖品的角度值,并向下取整,就能获得这是第几个奖品
    
    return awards[index];
}
复制代码

取值的运算方法看似有点复杂,实际上很简单,咱们只须要记住如下几点:

  1. 不管转盘转多少圈,任意角有多大,咱们均可以经过 startAngle % 360 求余数,来计算出,转盘在中止旋转后,起始角在360°范围内的角度;

  2. 假如,咱们有四个奖项,那么每一个奖项对应的角度就是 90°;为了方便计算,咱们将 pointerAngle 的值设置为0,也就是 0°所在位置的奖项会被输出;那么当起始角变成了 10°,剩余的角度总和就是 350°,用 350° 除以 每一个奖项的角度 90°,再将获得的值向下取整,值为3,咱们就得到了 0°指针,指向转盘起始角为10° 时的奖品数组下标了!


结语:

大转盘里涉及了一些基本的数学知识,三角函数,圆周率等。若是同窗以为看着有些吃力,赶忙回去看看初中数学吧💥。 下期为你们奉上九宫格抽奖,敬请期待🙃

相关文章
相关标签/搜索