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

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

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

Github 仓库 | demo 预览

扫描二维码预览demo

项目结构:

因为本章代码比较繁杂,我不会全部贴出来;建议进入我的 Github 仓库,找到 test 文件下的 sudoku文件夹下载,本章讲解的代码都在里面啦。

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


绘制九宫格:

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

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

图1

敲敲数字,鸟枪变大炮。不管你9宫还是12宫还是自宫,哥都不怕。

以下是我的实现方法,如果大家有更简单的方法,请告诉我,请告诉我,请告诉我,学美术出生的我数学真的很烂~


  • 九宫格的四个顶点

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


每行每列小方块数量左上角右上角右下角左下角
3个0246
4个0369
5个04812


如图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

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

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值