canvas 基础系列(三)之实现九宫格抽奖

上一章讲解了如何使用 canvas 实现大转盘抽奖点击回顾;但有些地方并无讲清楚,好比上一章实现的大转盘,奖品选项只能填文字,而不能放图片上去。 这一次,咱们用 canvas 来实现九宫格抽奖(我已沉迷抽奖没法自拔~),顺便将渲染图片功能也给你们过一遍。 本章涉及到的知识点,主要有:javascript

  1. context.drawImage() 方法渲染图片
  2. context.isPointInPath() 方法,在画布中制做按钮
  3. setTimeout() 方法,来作逐帧动画
  4. 九宫格的绘制算法

Github 仓库 | demo 预览html

扫描二维码预览demo

项目结构:

由于本章代码比较繁杂,我不会所有贴出来;建议进入个人 Github 仓库,找到 test 文件下的 sudoku文件夹下载,本章讲解的代码都在里面啦。java

|--- js
|--- | --- variable.js  # 包含了全部全局变量
|--- | --- global.js    # 包含了本项目所用到的公用方法
|--- | --- index.js     # 九宫格主体逻辑代码
|--- index.html
复制代码

绘制九宫格:

首先,咱们须要绘制出一个九宫格,你们都知道九宫格长什么样子哈,简单的排9个方块,不就搞定了么?git

不不不,做为一名合格的搬砖工,咱们须要严于律己,写代码要抽象,要能重用,要... 假如哪天产品大大说,我要12宫格儿的,15的,20的,你咋办,一个个从新算额~ 因此,咱们得作成图1这样的:github

图1

敲敲数字,鸟枪变大炮。无论你9宫仍是12宫仍是自宫,哥都不怕。算法

如下是个人实现方法,若是你们有更简单的方法,请告诉我,请告诉我,请告诉我,学美术出生的我数学真的很烂~gulp


  • 九宫格的四个顶点

咱们将九宫格看作一个完整的矩形,矩形有四个顶点; 假设每一行每一列,咱们只显示3个小方块(也就是传统的九宫格),那么四个顶点上的小方块序号分别是,0, 2, 4, 6 ; 假设每一行每一列,咱们显示4个小方块,那么四个顶点上的小方块序号分别是,0, 3, 6, 9; 以此类推,每行每列显示5个小方块,就是 0, 4, 8, 12canvas


每行每列小方块数量 左上角 右上角 右下角 左下角
3个 0 2 4 6
4个 0 3 6 9
5个 0 4 8 12

如图2:跨域

图2

聪明的小伙伴们应该已经发现规律了,在图1中,咱们使用的神秘变量 AWARDS_ROW_LEN ,它的做用就是指定九宫格每行每列显示多少个小方块;数组

接着,咱们绘制的原理是:分红四步,从每个顶点开始绘制小方块,直到碰到下一个顶点为止;

咱们会发现,当 AWARDS_ROW_LEN = 3 时,咱们从 0 ~ 1,从 2 ~ 3... ,每一次绘制两个小方块;

AWARDS_ROW_LEN = 4 时,咱们从0 ~ 2,从 3 ~ 5,每一次绘制三个小方块,绘制的步数恰好是 AWARDS_ROW_LEN - 1;如图3:

图3
因此咱们得出一个变量 AWARDS_TOP_DRAW_LEN,来表示不一样状况下,每一个顶点绘制的步数;

咱们经过 AWARDS_TOP_DRAW_LEN 这个变量,又能够推算出,任何状况下,矩形四个顶点所在的小方块的下标:

你能够列举多种状况,来验证该公式的正确性

LETF_TOP_POINT =     0,
RIGHT_TOP_POINT =    AWARDS_TOP_DRAW_LEN,
RIGHT_BOTTOM_POINT = AWARDS_TOP_DRAW_LEN * 2,
LEFT_BOTTOM_POINT =  AWARDS_TOP_DRAW_LEN * 2 + AWARDS_TOP_DRAW_LEN,
复制代码

  • 经过四个顶点,绘制九宫格

获得了每一个顶点的下标,那就意味着咱们知道了一个顶点距离另外一个顶点之间,有多少个小方块,那么接下来就很是好办了,

  1. 咱们能够经过 AWARDS_TOP_DRAW_LEN 乘以4,来获取总的奖品个数,做为循环条件(AWARDS_LEN);
  2. 咱们能够获取整个矩形的宽度,默认就让它等于 canvas 的宽度(SUDOKU_SIZE);
  3. 自定义每一个小方块之间的间距(SUDOKU_ITEM_MARGIN);
  4. 经过矩形的宽度除以一排绘制的小方块的数量,再减去小方块之间的间距,获得每一个小方块的尺寸(SUDOKU_ITEM_SIZE)。

变量有点多·若是你感受有点懵逼,请仔细查阅源码 variable.js 中的变量,搞懂每一个变量的表明的意义。


咱们已经拿到全部绘制的条件,接下来只须要写个循环,轻松搞定!

function drawSudoku() {
    context.clearRect(0, 0, canvas.width, canvas.height);

    for (let i = 0; i < AWARDS_LEN; i ++) {

        // 顶点的坐标
        let max_position = AWARDS_TOP_DRAW_LEN * SUDOKU_ITEM_SIZE + AWARDS_TOP_DRAW_LEN * SUDOKU_ITEM_MARGIN;

        // ----- 左上顶点
        if (i >= LETF_TOP_POINT && i < RIGHT_TOP_POINT) {
            let row = i,
                x = row * SUDOKU_ITEM_SIZE + row * SUDOKU_ITEM_MARGIN,
                y = 0;

            // 记录每个方块的坐标
            positions.push({x, y});

            // 绘制方块
            drawSudokuItem(
                x, y, SUDOKU_ITEM_SIZE, SUDOKU_ITEM_RADIUS,
                awards[i], SUDOKU_ITEM_TXT_SIZE, SUDOKU_ITEM_UNACTIVE_TXT_COLOR,
                SUDOKU_ITEM_UNACTIVE_COLOR,
                SUDOKU_ITEM_SHADOW_COLOR
            );
        };
        // -----

        // ----- 右上顶点
        if (i >= RIGHT_TOP_POINT && i < RIGHT_BOTTOM_POINT) {
            // ...
        };
        // -----

        // ----- 右下顶点
        if (i >= RIGHT_BOTTOM_POINT && i < LEFT_BOTTOM_POINT) {
            // ...
        }
        // -----

        // ----- 左下顶点
        if (i >= LEFT_BOTTOM_POINT) {
            // ...
        }
        // -----
    };
}
复制代码

  • drawSudokuItem() 函数方法

在绘制九宫格的 drawSudoku() 函数方法中,你会发现,咱们每一步绘制,都将当前小方块的坐标推到了一个 positions 的全局变量中;

这个变量会记录全部小方块的坐标,以及他们的下标;

以后咱们在绘制轮跳的小方块时,就可以经过 setTimeout() 定时器,规定每隔一段时间,经过下标值 jump_index 取出 positions 变量中的某一组坐标信息,并经过该信息中的坐标绘制一个新的小方块,覆盖到原来的小方块上,结束绘制后,jump_index的值递增;

这便实现了九宫格的轮跳效果。

而绘制这些小方块,咱们封装了一个公共的方法:drawSudokuItem();

/** * 绘制单个小方块 * @param {Num} x 坐标 * @param {Num} y 坐标 * @param {Num} size 小方块的尺寸 * @param {Num} radius 小方块的圆角大小 * @param {Str} text 文字内容 * @param {Str} txtSize 文字大小样式 * @param {Str} txtColor 文字颜色 * @param {Str} bgColor 背景颜色 * @param {Str} shadowColor 底部厚度颜色 */
function drawSudokuItem(x, y, size, radius, text, txtSize, txtColor, bgColor, shadowColor) {
    // ----- 绘制方块
    context.save();
    context.fillStyle = bgColor;
    context.shadowOffsetX = 0;
    context.shadowOffsetY = 4;
    context.shadowBlur = 0;
    context.shadowColor = shadowColor;
    context.beginPath();
    roundedRect(
        x, y,
        size, size, 
        radius
    );
    context.fill();
    context.restore();
    // -----

    // ----- 绘制图片与文字
    if (text) {
        if (text.substr(0, 3) === 'img') {
            let textFormat = text.replace('img-', ''),
                image = new Image();
                image.src = textFormat;

            function drawImage() {
                context.drawImage(
                    image, 
                    x + (size * .2 / 2), y + (size * .2 / 2), 
                    size * .8, size * .8
                );
            };

            // ----- 若是图片没有加载,则加载,如已加载,则直接绘制
            if (!image.complete) {
                image.onload = function (e) {
                    drawImage();
                }
            } else {
                drawImage();
            }
            // -----
        }
        else {
            context.save();
            context.fillStyle = txtColor;
            context.font = txtSize;
            context.translate(
                x + SUDOKU_ITEM_SIZE / 2 - context.measureText(text).width / 2,
                y + SUDOKU_ITEM_SIZE / 2 + 6
            );
            context.fillText(text, 0, 0);
            context.restore();
        }
    }
    // ----- 
}
复制代码

该方法是一个公共的绘制小方块的方法,它能在初始化时绘制全部“底层”小方块,在动画轮跳是,绘制那个移动中的小方块。


drawSudokuItem() 实现了哪些功能?

  1. 经过 global.js 中的一个 roundedRect() 方法,绘制了一个圆角矩形;(本章暂不讨论圆角矩形的绘制方法,若是你感兴趣,能够查看源码,或者 GG 一下)
  2. 咱们定义了一个全局变量 awards 数组来存储奖品信息,若是值是普通的字符串,则在小方块的正中绘制该字符串文字,若是值带有前缀 img- 咱们就将该字符串中的 url 地址,做为图片的地址,渲染到小方块上。

绘制方块没啥好讲的,若是你不想用 roudedRect() 方法,你能够直接把它替换成 context.rect(),除了不是圆角,效果彻底同样。


在这里重点说下 context.drawImage() 这个方法:

先清楚一个概念

  1. 所绘制的图像,叫作 源图像 source image
  2. 绘制到的地方叫作 目标canvas destination canvas

语法

context.drawImage(
    HTMLImageElement $image,
    int $sourceX, int $sourceY [ , int $sourceW, int $sourceH,
    int $destinationX, int $destinationY, int $destinationW, int $destinationH ]
)
复制代码

参数有点多哈,但本章用到的也就前五个,其中前三个是必选,后两个是可选参数:

$image       # 能够是 HTMLImageElement 类型的图像对象,
             # 也能够是 HTMLCanvasElement 类型的 canvas 对象,
             # 或 HTMLVideoElement 类型的视频对象
             # 也就是说,它能够将指定 图片,canvas,视频 绘制到指定的 canvas 画布上。
             
             # 能够看到,该方法能够绘制另外一个 canvas,
             # 咱们能够经过这个特性实现 离屏canvas;在之后的章节中我会详细的讲解。

$sourceX / Y # 源图像的坐标,用这两个参数控制图片的坐标位置。

$sourceW / H # 源图像的宽高,用这两个参数控制图片的宽度与高度。
复制代码

⚠️ 这个方法有两个坑:

  1. 因为图片地址跨域的👻问题,在本地跑是会报错的,因此咱们必须创建一个本地服务器来作测试;
  2. 若是调用该方法时,图片未被加载,则什么错都不报,就是不显示(任性吧?),解决方法,在 image.onload = function(e) {...} 回调中调用 context.drawImage()

若是你不知道怎么创建本地服务器的话,我...,愤怒的我当场百度了一篇最简单搭建服务器的教程,童叟无欺!gulp 搭建本地服务器教程


咱们来看如下代码:

if (text.substr(0, 3) === 'img') {
    let textFormat = text.replace('img-', ''),
        image = new Image();
        image.src = textFormat;

    function drawImage() {
        context.drawImage(
            image, 
            x + (size * .2 / 2), y + (size * .2 / 2), 
            size * .8, size * .8
        );
    };

    // ----- 若是图片没有加载,则加载,如已加载,则直接绘制
    if (!image.complete) {
        image.onload = function (e) {
            drawImage();
        }
    } else {
        drawImage();
    }
    // -----
}
复制代码
  1. 先检测获取的文本字符串是否含有前缀 img,若是有,便开始绘制图片;
  2. 将文本的前缀去除,格式化后保留完整的连接地址;新建一个 image 对象,将该对象的 src 属性赋值;
  3. 定义一个 drawImage() 函数方法,在该方法里面,使用 context.drawImage() 方法渲染刚刚定义的 image 对象,并指定相应的图片大小,和尺寸;
  4. 经过 image.complete 来判断图片是否已加载完成,若是未加载,则先初始化,在 image.onload = function(e) {...} 的回调中调用 drawImage() 方法;若是已经加载完毕,则直接调用 drawImage() 方法。

以上,图片就这样渲染完成了,渲染普通文本就不用说了哈,就是普通的 context.fillText() 方法。


绘制按钮:

咱们已经将外围的小方块绘制完成了,接下来来制做中间的按钮。

按钮的绘制很简单,你们看看源码, 就能轻松理解;

可是这个按钮在 canvas 中,只不过就是一堆像素组成的色块,它不能像 html 中定义的按钮那样,具备点击,鼠标移动等交互功能;

若是咱们想在 canvas 中实现一个按钮,那咱们只能规定当咱们点击 canvas 画布中的某一个区域时,给予用户反馈;


🎉 这里引入一个新的方法,context.isPointInPath(); 人如其名,该方法会判断:当前坐标点,是否在当前路径中,若是在,返回 true,不然返回 false。


语法

context.isPointInPath(int $currentX, int $currentY)
复制代码

两个参数就表明须要进行判断的坐标点。


经过这个方法,咱们能够判断:当前用户点击的位置的坐标,是否位于按钮的路径中,若是返回 true,则执行抽奖动画。


⚠️ 值得注意的是,判断的路径,必须是当前路径,也就是说,咱们在执行判断以前须要从新绘制一遍按钮的路径;源码中的 createButtonPath() 就是为了作这件事情存在的。


咱们来作一个简单的小测试,测试效果如图4:

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

function windowToCanvas(e) {
    var bbox = canvas.getBoundingClientRect(),
        x = e.clientX,
        y = e.clientY;

    return {
        x: x - bbox.left,
        y: y - bbox.top
    }
}

context.beginPath();
context.rect(100, 100, 100, 100);
context.stroke();

canvas.addEventListener('click', function (e) {
    var loc = windowToCanvas(e);
    if (context.isPointInPath(loc.x, loc.y)) {
        alert('🎉')
    }
});
复制代码

图4

怎么样?炒鸡简单对吧?在咱们这个项目中也是同样的:

  1. 咱们在绘制按钮的时候,将按钮的坐标信息已经推送到了 button_position 这个变量中;
  2. 咱们只须要经过这些信息建立一个同样的按钮路径;(只要你不填充路径,路径是不会显示的);
  3. 建立的路径成为了 当前路径,咱们将点击事件 click 中获取的坐标信息传给 context.isPointInPath() 方法,就能够判断,当前的位置,是否在按钮的路径中。
['mousedown', 'touchstart'].forEach((event) => {
    canvas.addEventListener(event, (e) => {
        let loc = windowToCanvas(e);

        // 建立一段新的按钮路径,
        createButtonPath();

        // 判断当前鼠标点击 canvas 的位置,是否在当前路径中,
        // 若是为 true,则开始抽奖
        if (context.isPointInPath(loc.x, loc.y) && !is_animate) {
            // ...
        }
    })
});
复制代码

咱们将经过点击按钮,来调用 animate() 方法,该方法实现了九宫格抽奖的动画效果。


实现动画:

在点击按钮时,咱们会初始化三个全局变量,jumping_time, jumping_total_time, jumping_change

它们分别表示:动画当前时间计时;动画花费的时间总长;动画速率改变的峰值(使用 easeOut 函数方法,单位时间内会将速率由0提高到峰值);

最后咱们将调用 animate() 函数方法,如下是该方法的代码:

function animate() {
    is_animate = true;

    if (jump_index < AWARDS_LEN - 1)       jump_index ++;
    else if (jump_index >= AWARDS_LEN -1 ) jump_index = 0;

    jumping_time += 100;  // 每一帧执行 setTimeout 方法所消耗的时间

    // 当前时间大于时间总量后,退出动画,清算奖品
    if (jumping_time >= jumping_total_time) {
        is_animate = false;
        if (jump_index != 0)       alert(`🎉恭喜您中得:${awards[jump_index - 1]}`)
        else if (jump_index === 0) alert(`🎉恭喜您中得:${awards[AWARDS_LEN - 1]}`);
        return;
    };

    // ----- 绘制轮跳方块
    drawSudoku();
    drawSudokuItem(
        positions[jump_index].x, positions[jump_index].y,
        SUDOKU_ITEM_SIZE, SUDOKU_ITEM_RADIUS, 
        awards[jump_index], SUDOKU_ITEM_TXT_SIZE, SUDOKU_ITEM_ACTIVE_TXT_COLOR,
        SUDOKU_ITEM_ACTIVE_COLOR,
        SUDOKU_ITEM_SHADOW_COLOR
    );
    // -----

    setTimeout(animate, easeOut(jumping_time, 0, jumping_change, jumping_total_time))
}
复制代码

animate() 函数方法:

  1. 咱们定义了一个全局变量 is_animate,该变量用来阻止用户在动画进行时反复点击按钮,使动画不断被调用;该变量初始值为 false,仅当该变量为 false 时,点击按钮才会进入 animate() 函数;当进入 animate() 函数后,该变量被设置为 true,结束动画时,又被重置为 false
  2. jump_index 全局变量的初始值是一个小于等于奖品总数的随机正整数;随着每一帧动画的执行递增,但当他等于奖品总数时,又会被重置为 0,以此循环;咱们使用该变量,来绘制轮跳的小方块;
  3. jumping_time 全局变量初始值为0,随着每一帧动画的执行递增,以此来记录动画当前时间点,当这个值大于等于时间总量 jumping_total_time 时,就能够结束动画,并将当前的 jump_index 取出,做为抽中的奖品了;
  4. drawSudoku() 方法中第一句代码就是:context.clearRect(0, 0 , canvas.width, canvas.height);它用于清理整个画板,并将九宫格重绘出来;
  5. drawSudokuItem() 咱们使用这个函数方法,来绘制轮跳的小方块;前面说过,咱们将 jump_index 作为下标,那么咱们就能够在 positions 变量中找到坐标信息,从 awards 变量中,找到奖品信息;
  6. 最后,咱们使用定时器 setTimeout() 方法,来实现小方块的动画;该方法调用 animate() 方法自己,它的第二个参数,咱们使用了上一章介绍过的缓动函数来定义,这会使动画看上去由快到慢;缓动函数的源码能够在 global.js 中找到。

O 啦~全部代码讲解完毕,你的九宫格是否也动起来了?😍


结语:

canvas 实现动画的方式不外乎就是清除画板,再从新绘制一个 动做 ,理解了它,不管你是用 window.requestAnimateFrame() 仍是 setTimeout() 和 setInterval() 来作动画,都是同样的原理;

九宫格的实现很简单,惟一复杂点的,就须要一系列计算,来绘制一个灵活的九宫格;

九宫格不只能够用来抽奖,也能够用来作一些小游戏,还记得小时候玩过的老虎机么?如图5:

图5

改改样式,找点图片,把值取出来作下分数规则判断,分分钟搞定呢!

相关文章
相关标签/搜索