目的
HTML5中的canvas提供了使用JS脚本在网页上绘制图形的能力,传统的HTML对象往往只能作为矩形或者圆形容器存在,而直接使用图片又比较复杂,且需要适配不同分辨率。因此,本着试一试也不亏想法,作者使用canvas
创建了一个抽奖圆盘,配合动画实现抽奖地效果,整个转盘封装成了一个类,并且提供了抽奖方法。效果图如下,感兴趣地可以看一看:
过程
由于对API不是非常熟悉,因此实际的过程比较凌乱,大致来说分为动画实现,转盘绘制,文字绘制三步。参考的网站主要就是MDN:
动画实现
HTML提供了基础的CSS动画keyframes
,通过关键帧确定某几个时刻的属性值,然后根据timing-function
自动绘制属性的过渡变化过程。例如如下代码表示一个从背景色紫色,旋转0度
变化到背景色蓝色,旋转720度
的一个动画,并将其应用到#canvas
元素上,设置为2s:
@keyframes rot {
0% {
transform: rotate(0deg);
background-color: rebeccapurple;
}
100% {
transform: rotate(720deg);
background-color: dodgerblue;
}
}
#canvas {
animation: rot 2s ease, forwards;
}
不过CSS动画是已经写好的,因此需要使用JS编写动画:
ani = canvas.animate([{ transform: 'rotate(720deg)' }], {
duration: 2 * 1000,
fill: "forwards",
easing: "ease"
});
如果需要多次开启动画,可以使用这里获取的ani.cancel()
方法取消动画,然后再调用animate
就可以了。
转盘实现
转盘包括两部分,地盘部分和上面指针部分。目的是让地盘旋转,因此上面的指针需要在设置一个canvas
元素。这里使用position: absolute
就能达到堆叠的效果了。
-
指针绘制 - 先画个三角形再画个圆形,非常easy
drawPointer() { let context = this.context, radius = this.radius; // 指针下部 - 圆形 context.fillStyle = "#ff2233"; context.beginPath(); // 圆弧,这里直接就是个圆了 context.arc(radius, radius, radius / 10, 0, Math.PI * 2); context.closePath(); context.fill(); // 指针头 - 三角形 context.beginPath(); // 右下角 context.moveTo(radius + radius / 15, radius); // 顶角 context.lineTo(radius, radius / 6); // 左下角 context.lineTo(radius - radius / 15, radius); context.closePath(); context.fill(); }
-
圆盘绘制
圆盘的绘制需要等分圆盘,然后每一部分填充个扇形。这里使用
ctx.arc()
配合ctx.closePath()
即可快速绘制。绘制的时候参数是弧度,需要做一下转换。// 绘制一个扇形并使用颜色填充 fillSector(fromDeg, toDeg, color) { const context = this.context; const radius = this.radius; context.beginPath(); context.fillStyle = color; context.moveTo(radius, radius); context.arc(radius, radius, radius, fromDeg * Math.PI / 180, toDeg * Math.PI / 180); context.closePath(); context.fill(); } // 将圆等分并给每一块填上不同颜色 divideCircle(n, colors) { const degWidth = 360 / n; for (let i = 0; i < n; i++) { const fromDeg = degWidth * i; this.fillSector(degWidth * i, degWidth * (i + 1), colors[i]); } }
转盘文字绘制
文字绘制本身很简单,但由于需要在转盘的不同位置绘制,所以会牵涉到canvas
坐标的旋转变换。
所谓的坐标变化,就是对canvas的整个坐标系进行平移、旋转等操作。变换之后,画布上的绘制操作如绘制线条、矩形、绘制文字、圆弧等都会以变换后的坐标系为基准。以MDN的例子来说,灰色矩形为坐标轴变化前绘制的,红色矩形为以左上角为圆心,顺时针旋转45°后绘制的,如下。
// 为旋转,正常坐标系的灰色矩形
ctx.fillStyle = 'gray';
ctx.fillRect(100, 0, 80, 20);
// 旋转坐标系后的红色矩形
ctx.rotate(45 * Math.PI / 180); // 旋转坐标系
ctx.fillStyle = 'red';
ctx.fillRect(100, 0, 80, 20)
无论做了什么变换,都可以通过ctx.setTransform(1, 0, 0, 1, 0, 0)
重置坐标系。
完整代码
实际上转盘旋转的过程也需要思考一下,但是因为是基本的数学知识,所以也不再赘述了,完整代码如下(只有一个html)。因为进行了封装,所以如颜色、旋转时间、文字大小等都是可以自定义的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body,
html {
width: 100%;
height: 100%;
background-color: white;
margin: 0px;
}
@keyframes rot {
0% {
transform: rotate(0deg);
background-color: rebeccapurple;
}
100% {
transform: rotate(720deg);
background-color: dodgerblue;
}
}
#plate {
width: 30vw;
height: 30vw;
margin: auto;
border: solid rgb(247, 67, 67) 12px;
border-radius: 50%;
background-color: #fff;
}
#canvas,
#canvas2 {
position: absolute;
margin: auto;
/* animation: rot 2s ease, forwards; */
border-radius: 50%;
}
#btn,
input {
width: 60%;
margin: 5px 100px;
}
</style>
<script>
class DrawingContext {
defaultSec = 4;
defaultDegBonus = 360 * 3;
defaultDeg = 90;
ani = null;
timeoutTask = null;
shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
var x = a[i];
a[i] = a[j];
a[j] = x;
}
return a;
}
constructor(plate, canvas) {
this.plate = plate;
this.canvas = canvas;
this.width = plate.clientWidth;
this.height = plate.clientHeight;
this.radius = this.width / 2;
canvas.setAttribute("width", this.width);
canvas.setAttribute("height", this.width);
this.context = this.canvas.getContext("2d");
this.canvas.onanimationend = (ev) => this.canvas.style.animation = "";
}
// 重设动画
resetAni() {
if (this.timeoutTask) {
clearTimeout(this.timeoutTask);
}
if (this.ani) {
this.ani.cancel();
}
this.timeoutTask = null;
this.ani = null;
}
// 重设动画,重绘圆盘
redraw(n, colors, texts) {
this.resetAni();
this.drawLottery(n, colors, texts);
}
// 重设动画,重绘圆盘,开启抽奖,返回中奖目标
letsGoLottery(n, colors, texts) {
this.redraw(n, this.shuffle(colors), texts);
const who = Math.floor(Math.random() * n);
this.timeoutTask = setTimeout(() => {
const degWidth = 360 / n;
var degToGo = (2 * n - who - Math.ceil(n / 4)) % n * degWidth - (n % 4 == 0 ? degWidth / 2 : 0);
console.log(degToGo);
this.setAnimation(degToGo);
}, 800);
return who;
}
// 启动一个旋转的动画
setAnimation(deg, sec) {
if (!deg) deg = this.defaultDeg;
if (!sec) sec = this.defaultSec;
deg += this.defaultDegBonus;
this.ani = this.canvas.animate(
[{ transform: 'rotate(' + deg + 'deg)' }],
{
duration: sec * 1000,
fill: "forwards",
easing: "ease"
});
}
// 绘制一个扇形并使用颜色填充
fillSector(fromDeg, toDeg, color) {
const context = this.context;
const radius = this.radius;
context.beginPath();
context.fillStyle = color;
context.moveTo(radius, radius);
context.arc(radius, radius, radius, fromDeg * Math.PI / 180, toDeg * Math.PI / 180);
context.closePath();
context.fill();
}
// 将圆等分并给每一块填上不同颜色
divideCircle(n, colors) {
const degWidth = 360 / n;
for (let i = 0; i < n; i++) {
const fromDeg = degWidth * i;
this.fillSector(degWidth * i, degWidth * (i + 1), colors[i]);
}
}
// 画n个均匀分布在园上的文字
drawText(n, texts) {
var context = this.context;
context.font = "20px '等线'";
context.fillStyle = "#000";
context.textAlign = "center";
context.translate(this.radius, this.radius);
const deg = 360 / n * Math.PI / 180;
context.rotate(Math.PI / 2 - deg / 2);
for (let i = 1; i <= n; i++) {
context.rotate(deg);
context.fillText(texts[i - 1], 0, -this.radius * 4 / 5, deg * this.radius * 4 / 5);
}
context.setTransform(1, 0, 0, 1, 0, 0);
}
// 抽奖转盘 - 园等分成n个扇形,每个扇形有文字
drawLottery(n, colors, texts) {
this.divideCircle(n, colors);
this.drawText(n, texts);
}
// 绘制圆盘指针
drawPointer() {
let context = this.context, radius = this.radius;
// 指针下部
context.fillStyle = "#ff2233";
context.beginPath();
context.arc(radius, radius, radius / 10, 0, Math.PI * 2);
context.closePath();
context.fill();
// 指针头
context.beginPath();
context.moveTo(radius + radius / 15, radius);
context.lineTo(radius, radius / 6);
context.lineTo(radius - radius / 15, radius);
context.closePath();
context.fill();
}
};
function randColor() {
const cl = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
var res = "#";
for (let i = 0; i < 6; i++) res += cl[Math.floor(Math.random() * (cl.length))];
return res;
};
window.onload = function () {
var colors = ["pink", "purple", "cyan", "green", "gold", "brown", "crimson", "dodgerBlue", "tomato", "MediumAquaMarine"];
var n = 10, texts = colors;
var drawContext = new DrawingContext(document.getElementById("plate"), document.getElementById("canvas"));
var drawContext2 = new DrawingContext(document.getElementById("plate"), document.getElementById("canvas2"));
drawContext.divideCircle(n, colors);
drawContext2.drawPointer();
document.getElementById("btn").onclick = function (event) {
const who = drawContext.letsGoLottery(n, colors, texts);
alert(texts[who]);
}
}
</script>
</head>
<body>
<div id="plate">
<canvas id="canvas"></canvas>
<canvas id="canvas2"></canvas>
</div>
<div>
<button id="btn">点啊</button>
<!-- <input type="number" name="helo" placeholder="num" id="inputN"> -->
<!-- <input type="number" name="halo" placeholder="degree" id="inputDeg">
<input type="number" name="hhlo" placeholder="time" id="inputTime"> -->
</div>
</body>
</html>
总结
边查边写,边写便理解。如有纰漏,还望指正。