最近需要实现老虎机抽奖效果,封装了一下抽奖转盘的动画实现。 大概长这样👇:
在线体验地址:lvrflh.csb.app/。
抽奖动画实现
动画(英语:Animation)是一种通过定时拍摄一系列多个静止的固态图像(帧)以一定频率连续变化、运动(播放)的速度(如每秒16张)而导致肉眼的视觉残象产生的错觉——而误以为图画或物体(画面)活动的作品及其视频技术。 ——维基百科
要实现动画,就需要在网页绘制每一帧的时候更新对应的内容,因此实现动画可以基于requestAnimationFrame,也可以使用定时器(setTimeout)间隔16.6ms绘制一帧来实现。
抽奖机的难点在于如何实现连贯的动画,具体流程如下:
- 点击开始按钮
- 从起始位置开始滚动
- 每经过一个item,就将其高亮
- 先加速后减速,不可跳跃
- 停留到目标位置
假设我们的奖项一共6个,第一次抽奖从0开始,之后每次抽奖都从上一次的位置开始。
我们一步一步来实现抽奖动画:
定义Lottery类
class Lottery {
lock = false; // 执行抽奖时进行加锁
turns = 6; // 需要旋转多少圈
numOfRewards = 6; // 有多少个奖励项
duration = 6000; // 动画持续时长
walkPath = [0, 1, 2, 5, 4, 3]; // 动画高亮路径
activeIndex = 0; // 当前激活项
constructor(onRefresh, onStop) {
this.onRefresh = onRefresh; // 执行每一帧时触发的回调函数
this.onStop = onStop; // 完成抽奖后执行的回调函数
}
// 开始抽奖
start(targetIndex) {}
}
动画实现
动画存在三要素:
- 开始时间
- 完成百分比
- 结束时间
其中从开始到结束这段持续时间里,如果单位时间里(每一帧)动画的完成百分比是一样的,那么则是做的匀速运动(linear);如果完成百分比开始和结束比较慢,中间比较快,那么则是做的一种变速运动,如:easyInOutCubic等;
当然我们可以更加精细的控制中间这一过程,实现如下效果:
图像来源:常见的缓动曲线及其公式
我们可以通过定义开始和结束时间,在每一帧的时候通过辅助函数(缓动曲线)来计算当前进度,从而实现动画效果:(为了更好的贴合抽奖效果,这里采用了easeOutCubic
函数)
class Lottery {
...
// 开始抽奖
start(targetIndex) {
const beginTime = Date.now(); // 记录开始时间
const rAF =
window.requestAnimationFrame || ((func) => setTimeout(func, 16));
// 缓动曲线参考:https://blog.csdn.net/Mr_Sun88/article/details/130028371
const easeOutCubic = (value) => 1 - Math.pow(1 - value, 3);
const frameFunc = () => {
const progress = (Date.now() - beginTime) / this.duration;
if (progress < 1) {
const finishRatio = easeOutCubic(progress)
// 绘制一帧
...
// 绘制下一帧
rAF(frameFunc);
} else {
// 完成绘制
}
};
rAF(frameFunc);
}
}
映射选中项
我们的奖项一共6项,整个过程循环往复。该如何将动画应用到这上面?
我们可以将 [0, 1] 这个区间划分为 turns
* numOfRewards
,也就是36份,得到offset
,每一次执行动画的时候,计算当前的完成进度,除以offset
并向下取整,得到一个数,这个数%
(模) numOfRewards
,就得到了当前高亮的下标:
具体代码:
const frameFunc = () => {
const progress = (Date.now() - beginTime) / this.duration;
if (progress < 1) {
const index = Math.floor(easeOutCubic(progress) / offset) % this.numOfRewards;
this.activeIndex = walkPath[index];
this.onRefresh(this.activeIndex);
rAF(frameFunc);
} else {
this.onStop(this.activeIndex);
this.lock = false;
}
};
有了下标之后,为了按照圆圈的形式进行动画,我设置了walkPath
来表示当前高亮的真正路径。如上面的:walkPath = [0, 1, 2, 5, 4, 3]; // 动画高亮路径
在上图中我们完整的跑完了6圈,也就是最终的目标项设置为了最后一项(上图中的4),我们也可以调整最后一圈的完成个数来动态控制最终选中的项目,这就需要动态计算一个格子的停留时长区间:
const offset = 1 / ((this.turns - 1) * this.numOfRewards + targetIndex + 1);
重新启动
为了使下一次抽奖的时候,从上一次的位置开始,我们需要重置walkPath
:
主要就是把当前激活项作为开始,并把他之前的项挪到后面:
class Lottery {
...
resetWalkPath() {
const walkPath = this.walkPath.slice();
const realIndex = walkPath[this.activeIndex];
const front = walkPath.splice(realIndex);
walkPath.unshift(...front);
return walkPath;
}
...
}
完整动画
class Lottery {
...
start(targetIndex) {
if (this.lock) {
return;
}
this.lock = true;
const beginTime = Date.now(); // 记录开始时间
const walkPath = this.resetWalkPath();
targetIndex = walkPath.indexOf(targetIndex); // 最终停留位置
targetIndex = targetIndex % this.numOfRewards;
const offset = 1 / ((this.turns - 1) * this.numOfRewards + targetIndex + 1); // 有numOfRewards个格子
const rAF =
window.requestAnimationFrame || ((func) => setTimeout(func, 16));
// 缓动曲线参考:https://blog.csdn.net/Mr_Sun88/article/details/130028371
const easeOutCubic = (value) => 1 - Math.pow(1 - value, 3);
const frameFunc = () => {
const progress = (Date.now() - beginTime) / this.duration;
if (progress < 0.8) {
const index =
Math.floor(easeOutCubic(progress) / offset) % this.numOfRewards;
this.activeIndex = walkPath[index];
this.onRefresh(this.activeIndex);
rAF(frameFunc);
} else {
this.onStop(this.activeIndex);
this.lock = false;
}
};
rAF(frameFunc);
}
}
按钮监听
完成了对Lottery的实现,现在我们需要监听用户点击按钮事件,并在回调函数中启动抽奖机:
const lottery = new Lottery(
(activeIndex) => {
const list = Array.from(document.querySelectorAll(".lottery-item"));
const len = list.length;
for (let i = 0; i < len; i++) {
const item = list[i];
item.classList.remove("bingo");
if (i === activeIndex) {
item.classList.add("bingo");
}
}
},
(activeIndex) => {
const el = document.querySelector(".reward-num");
el.innerText = activeIndex + 1;
}
);
const button = document.querySelector(".start-btn");
button.onclick = () => {
// 模拟随机抽奖,这里可以替换为从后台接口获取抽奖结果
lottery.start(Math.trunc(Math.random() * 6));
};
样式结构实现
结构样式部分比较简单,主要就是绘制出一个抽奖机的大概样式,由以下部分组成:
- 标题
- 奖励项
- 抽奖按钮
- 背景
详细代码如下:
html代码
<!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>HTML + CSS</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="container">
<h1>抽奖机</h1>
<img
class="bg"
src="https://t13fzac.h5.hitoupiao.wang/tmp_slots/images/bg.png?t=202303161410"
/>
<ul class="lottery-container">
<li class="lottery-item-container">
<div class="lottery-item">1</div>
</li>
<li class="lottery-item-container">
<div class="lottery-item">2</div>
</li>
<li class="lottery-item-container">
<div class="lottery-item">3</div>
</li>
<li class="lottery-item-container">
<div class="lottery-item">4</div>
</li>
<li class="lottery-item-container">
<div class="lottery-item">5</div>
</li>
<li class="lottery-item-container">
<div class="lottery-item">6</div>
</li>
</ul>
<button class="start-btn">开始抽奖</button>
<div class="res">
<span>恭喜抽中:</span>
<span class="reward-num">-</span>
<span>号奖项</span>
</div>
</div>
<script src="./index.js"></script>
</body>
</html>
css代码
* {
margin: 0;
padding: 0;
}
h1 {
text-align: center;
margin-top: 26px;
font-size: 16px;
font-weight: 400;
color: #fff;
}
.bg {
position: absolute;
z-index: -1;
top: -60px;
width: 100%;
}
.container {
position: absolute;
left: 50%;
top: 70px;
width: 200px;
height: 300px;
transform: translateX(-50%);
}
.lottery-container {
list-style: none;
display: flex;
justify-content: space-around;
flex-wrap: wrap;
align-items: center;
width: 106px;
height: 70px;
position: absolute;
left: 50%;
top: 60px;
transform: translateX(-50%);
}
.lottery-item-container {
width: 30px;
height: 30px;
}
.lottery-item {
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
position: relative;
width: 100%;
height: 100%;
border-radius: 9px;
background-image: linear-gradient(180deg, #fff 0%, #ffd3ca 100%);
}
.lottery-item.bingo {
width: 36px;
height: 36px;
left: -3px;
top: -3px;
background-image: linear-gradient(143deg, #fffcef 0%, #fff1b4 100%);
}
button {
padding: 6px;
margin-top: 30px;
position: absolute;
left: 50%;
bottom: 96px;
transform: translateX(-50%);
cursor: pointer;
}
.res {
position: absolute;
bottom: 0;
}
完整代码见:codesandbox
当然你也可以借助第三方开源库:lucky-canvas