基于rAF的抽奖动画实现

1,181 阅读5分钟

最近需要实现老虎机抽奖效果,封装了一下抽奖转盘的动画实现。 大概长这样👇:

lottery.gif

在线体验地址:lvrflh.csb.app/。

抽奖动画实现

动画(英语:Animation)是一种通过定时拍摄一系列多个静止的固态图像)以一定频率连续变化、运动(播放)的速度(如每秒16张)而导致肉眼的视觉残象产生的错觉——而误以为图画或物体(画面)活动的作品及其视频技术。 ——维基百科

要实现动画,就需要在网页绘制每一帧的时候更新对应的内容,因此实现动画可以基于requestAnimationFrame,也可以使用定时器(setTimeout)间隔16.6ms绘制一帧来实现。

抽奖机的难点在于如何实现连贯的动画,具体流程如下:

  1. 点击开始按钮
  2. 从起始位置开始滚动
  3. 每经过一个item,就将其高亮
  4. 先加速后减速,不可跳跃
  5. 停留到目标位置

假设我们的奖项一共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等;

当然我们可以更加精细的控制中间这一过程,实现如下效果:

image.png

图像来源:常见的缓动曲线及其公式

我们可以通过定义开始和结束时间,在每一帧的时候通过辅助函数(缓动曲线)来计算当前进度,从而实现动画效果:(为了更好的贴合抽奖效果,这里采用了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,就得到了当前高亮的下标:

image.png

具体代码:

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]; // 动画高亮路径

image.png

在上图中我们完整的跑完了6圈,也就是最终的目标项设置为了最后一项(上图中的4),我们也可以调整最后一圈的完成个数来动态控制最终选中的项目,这就需要动态计算一个格子的停留时长区间:

const offset = 1 / ((this.turns - 1) * this.numOfRewards + targetIndex + 1);

重新启动

为了使下一次抽奖的时候,从上一次的位置开始,我们需要重置walkPath: 主要就是把当前激活项作为开始,并把他之前的项挪到后面:

image.png

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));
};

样式结构实现

结构样式部分比较简单,主要就是绘制出一个抽奖机的大概样式,由以下部分组成:

  • 标题
  • 奖励项
  • 抽奖按钮
  • 背景

image.png

详细代码如下:

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

参考文档