上一章讲解了如何使用 canvas 实现大转盘抽奖点击回顾;但有些地方并没有讲清楚,比如上一章实现的大转盘,奖品选项只能填文字,而不能放图片上去。
这一次,我们用 canvas 来实现九宫格抽奖(我已沉迷抽奖无法自拔~),顺便将渲染图片功能也给大家过一遍。
本章涉及到的知识点,主要有:
context.drawImage()
方法渲染图片context.isPointInPath()
方法,在画布中制作按钮setTimeout()
方法,来做逐帧动画- 九宫格的绘制算法
Github 仓库 | demo 预览
扫描二维码预览demo项目结构:
因为本章代码比较繁杂,我不会全部贴出来;建议进入我的 Github 仓库,找到 test 文件下的 sudoku文件夹下载,本章讲解的代码都在里面啦。
|--- js
|--- | --- variable.js # 包含了所有全局变量
|--- | --- global.js # 包含了本项目所用到的公用方法
|--- | --- index.js # 九宫格主体逻辑代码
|--- index.html复制代码
绘制九宫格:
首先,我们需要绘制出一个九宫格,大家都知道九宫格长什么样子哈,简单的排9个方块,不就搞定了么?
不不不,作为一名合格的搬砖工,我们需要严于律己,写代码要抽象,要能重用,要...
假如哪天产品大大说,我要12宫格儿的,15的,20的,你咋办,一个个重新算额~
所以,我们得做成图1这样的:
敲敲数字,鸟枪变大炮。不管你9宫还是12宫还是自宫,哥都不怕。
以下是我的实现方法,如果大家有更简单的方法,请告诉我,请告诉我,请告诉我,学美术出生的我数学真的很烂~
- 九宫格的四个顶点
我们将九宫格看做一个完整的矩形,矩形有四个顶点;
假设每一行每一列,我们只显示3个小方块(也就是传统的九宫格),那么四个顶点上的小方块序号分别是,0, 2, 4, 6
;
假设每一行每一列,我们显示4个小方块,那么四个顶点上的小方块序号分别是,0, 3, 6, 9
;
以此类推,每行每列显示5个小方块,就是 0, 4, 8, 12
;
每行每列小方块数量 | 左上角 | 右上角 | 右下角 | 左下角 |
---|---|---|---|---|
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:
所以我们得出一个变量
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,复制代码
- 通过四个顶点,绘制九宫格
得到了每个顶点的下标,那就意味着我们知道了一个顶点距离另一个顶点之间,有多少个小方块,那么接下来就非常好办了,
- 我们可以通过
AWARDS_TOP_DRAW_LEN
乘以4,来获取总的奖品个数,作为循环条件(AWARDS_LEN
); - 我们可以获取整个矩形的宽度,默认就让它等于 canvas 的宽度(
SUDOKU_SIZE
); - 自定义每个小方块之间的间距(
SUDOKU_ITEM_MARGIN
); - 通过矩形的宽度除以一排绘制的小方块的数量,再减去小方块之间的间距,得到每个小方块的尺寸(
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() 实现了哪些功能?
- 通过
global.js
中的一个roundedRect()
方法,绘制了一个圆角矩形;(本章暂不讨论圆角矩形的绘制方法,如果你感兴趣,可以查看源码,或者 GG 一下) - 我们定义了一个全局变量
awards
数组来存储奖品信息,如果值是普通的字符串,则在小方块的正中绘制该字符串文字,如果值带有前缀img-
我们就将该字符串中的 url 地址,作为图片的地址,渲染到小方块上。
绘制方块没啥好讲的,如果你不想用 roudedRect()
方法,你可以直接把它替换成 context.rect()
,除了不是圆角,效果完全一样。
在这里重点说下 context.drawImage()
这个方法:
先清楚一个概念:
- 所绘制的图像,叫做 源图像
source image
; - 绘制到的地方叫做 目标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 # 源图像的宽高,用这两个参数控制图片的宽度与高度。复制代码
⚠️ 这个方法有两个坑:
- 由于图片地址跨域的?问题,在本地跑是会报错的,所以我们必须建立一个本地服务器来做测试;
- 如果调用该方法时,图片未被加载,则什么错都不报,就是不显示(任性吧?),解决方法,在
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();
}
// -----
}复制代码
- 先检测获取的文本字符串是否含有前缀
img
,如果有,便开始绘制图片; - 将文本的前缀去除,格式化后保留完整的链接地址;新建一个
image
对象,将该对象的src
属性赋值; - 定义一个
drawImage()
函数方法,在该方法里面,使用context.drawImage()
方法渲染刚刚定义的image
对象,并指定相应的图片大小,和尺寸; - 通过
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
怎么样?炒鸡简单对吧?在我们这个项目中也是一样的:
- 我们在绘制按钮的时候,将按钮的坐标信息已经推送到了
button_position
这个变量中; - 我们只需要通过这些信息创建一个一样的按钮路径;(只要你不填充路径,路径是不会显示的);
- 创建的路径成为了
当前路径
,我们将点击事件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() 函数方法:
- 我们定义了一个全局变量
is_animate
,该变量用来阻止用户在动画进行时反复点击按钮,使动画不断被调用;该变量初始值为false
,仅当该变量为false
时,点击按钮才会进入animate()
函数;当进入animate()
函数后,该变量被设置为true
,结束动画时,又被重置为false
; jump_index
全局变量的初始值是一个小于等于奖品总数的随机正整数;随着每一帧动画的执行递增,但当他等于奖品总数时,又会被重置为 0,以此循环;我们使用该变量,来绘制轮跳的小方块;jumping_time
全局变量初始值为0,随着每一帧动画的执行递增,以此来记录动画当前时间点,当这个值大于等于时间总量jumping_total_time
时,就可以结束动画,并将当前的jump_index
取出,作为抽中的奖品了;drawSudoku()
方法中第一句代码就是:context.clearRect(0, 0 , canvas.width, canvas.height)
;它用于清理整个画板,并将九宫格重绘出来;drawSudokuItem()
我们使用这个函数方法,来绘制轮跳的小方块;前面说过,我们将jump_index
做为下标,那么我们就可以在positions
变量中找到坐标信息,从awards
变量中,找到奖品信息;- 最后,我们使用定时器
setTimeout()
方法,来实现小方块的动画;该方法调用animate()
方法本身,它的第二个参数,我们使用了上一章介绍过的缓动函数来定义,这会使动画看上去由快到慢;缓动函数的源码可以在global.js
中找到。
O 啦~所有代码讲解完毕,你的九宫格是否也动起来了??
结语:
canvas 实现动画的方式不外乎就是清除画板,再重新绘制一个 动作
,理解了它,无论你是用 window.requestAnimateFrame()
还是 setTimeout() 和 setInterval()
来做动画,都是一样的原理;
九宫格的实现很简单,唯一复杂点的,就需要一系列计算,来绘制一个灵活的九宫格;
九宫格不仅可以用来抽奖,也可以用来做一些小游戏,还记得小时候玩过的老虎机么?如图5:
图5改改样式,找点图片,把值取出来做下分数规则判断,分分钟搞定呢!