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

9,654 阅读13分钟

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

项目预览链接地址

扫描二维码预览

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

<!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等全局变量,完成转盘的所有绘制操作,包括:圆盘,奖品选块,指针;

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


绘制大转盘

我们进入 drawRouletteWheel()方法,可以看到,该方法分为四步:

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

  • 清空页面所有元素

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


  • 绘制圆盘

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


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


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° 时的奖品数组下标了!


结语:

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