当前位置:   article > 正文

前端小程序,手把手教你从零开始做一个酷炫的扭蛋机十连抽动画效果_vue3 扭蛋抽奖

vue3 扭蛋抽奖

其实没有做多复杂的效果,连 canvas 都没用上,都是一些简单的平面变换,不过一段看似复杂的动画往往都是几个简单的变换拼接而成,所以我们逐步拆解,很简单的就能得到一个扭蛋机十连抽效果。

语言环境

我这边使用的是 tailwindcss 和 ts,在 uniapp  + vue3 的情况下写的小程序扭蛋机例子,不同框架下的同学可能要转换一下。

为了方便同学们学习,里面的素材都是远程图库的图片,可以直接取用。

先看效果

1.背景

先搭一个背景界面和主框体

  1. <!-- 扭蛋机积分抽奖 -->
  2. <template>
  3. <view class="page">
  4. <!-- 扭蛋机背景图片 -->
  5. <image
  6. class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
  7. src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
  8. />
  9. </view>
  10. </template>
  11. <script setup lang="ts">
  12. </script>
  13. <style lang="scss" scoped>
  14. .page {
  15. width: 100vw;
  16. min-height: 100vh;
  17. overflow: hidden;
  18. background-color: #ff0027;
  19. }
  20. </style>

就一个主背景,我在小程序上开发,所以单位用的 rpx,如果有用 px 做单位的,一般换算方法是 1px = 2rpx 。

2.弹幕动画:随机高度的左右平移

弹幕动画的本质是五张图片随机在界面上做平移处理,超出小程序界面则隐藏。

1.弹幕template:

        设定一个弹幕显示区域后,为其添加 overflow: hidden 属性,这样弹幕在进出容器边缘时隐藏。

        设置弹幕图片为  mode="heightFix" ,即等高情况下自适应宽度(实际开发情况根据你的素材做调整),由于我这里的素材故意设置的大小不同,因此在以同样的 transform 样式做平移的时候,速度上会有参差感。

2.弹幕scss

        样式中选择平移变换,这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios)

        然后动画中采用用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果

3.弹幕ts

        有两个属性需要用代码做随机设置,一个是弹幕的高度,一个是弹幕出场的时间,即动画延迟时间。每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)

        这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化。目前的效果是每次进入页面,随机一种弹幕位置效果。

4.弹幕模块完整代码(背景 + 弹幕)

  1. <!-- 扭蛋机积分抽奖 -->
  2. <template>
  3. <view class="page">
  4. <!-- 扭蛋机背景图片 -->
  5. <image
  6. class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
  7. src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
  8. />
  9. <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
  10. <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
  11. <!-- 弹幕 -->
  12. <image
  13. v-for="(barrage, i) in barrages"
  14. :key="i"
  15. mode="heightFix"
  16. class="absolute left-[760rpx] h-[88rpx] z-[300]"
  17. :class="{ 'barrage-animation': barrage.startAnimation }"
  18. :style="{ top: barrage.top + 'px' }"
  19. :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
  20. />
  21. </view>
  22. </view>
  23. </template>
  24. <script setup lang="ts">
  25. onLoad(async () => {
  26. startBarrageAnime() // 初始化静态弹幕效果
  27. })
  28. // 弹幕设置
  29. // 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
  30. const barrages = ref([
  31. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  32. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  33. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  34. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  35. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
  36. ])
  37. // 开始弹幕动画的函数
  38. // 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
  39. const startBarrageAnime = () => {
  40. // 遍历弹幕数组
  41. barrages.value.forEach((barrage, index) => {
  42. // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
  43. const randomTop = Math.floor(Math.random() * 200)
  44. // 计算随机延迟时间,使得弹幕不是同时出现
  45. const randomDelay = Math.random() * 10000
  46. // 设置延时函数,到达随机延迟时间后开始弹幕动画
  47. setTimeout(() => {
  48. barrage.top = randomTop // 设置弹幕的顶部位置
  49. barrage.startAnimation = true // 开始动画
  50. }, randomDelay)
  51. })
  52. }
  53. </script>
  54. <style lang="scss" scoped>
  55. .page {
  56. width: 100vw;
  57. min-height: 100vh;
  58. overflow: hidden;
  59. background-color: #ff0027;
  60. }
  61. /* 弹幕平移动画 */
  62. .barrage-animation {
  63. animation-name: moveLeft; /* 指定动画名称 */
  64. animation-duration: 10s; /* 指定动画时长 */
  65. animation-play-state: running; /* 控制动画播放状态 */
  66. animation-timing-function: linear; /* 指定动画速度曲线 */
  67. animation-iteration-count: infinite; /* 指定动画循环次数 */
  68. }
  69. /* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
  70. @keyframes moveLeft {
  71. /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  72. 0% { transform: translate3d(100%, 0, 0) }
  73. 100% { transform: translate3d(-500%, 0, 0) }
  74. }
  75. </style>

3.中奖记录:渐隐渐现的上下平移

        中奖记录的实现是通过左侧元素的上下平移再搭配透明度来实现,这里我是通过动态计算 top 的位置以及主动设置中间三项的透明度来实现。其实如果只是做效果,可以只用纯静态css,要简单很多,但是我这里要考虑到实际对接接口,而接口数据的长度是不可控的,因此通过脚本来灵活计算。

1.中奖记录template与scss:

这里没有太多介绍的,核心上面已经说了,通过控制 top 和 opacity 属性来控制动画,记得加上 transition: all 0.4s ease; 否则是没有过渡效果的。ellipsis 单行省略三件套是我喜欢用的样式,在设定宽度的情况下,它能让内部超出范围的文字截取并用省略号代替。

2.中奖记录ts:

通过 showTop 来设定最上面那条显示的中奖记录的高度,这个值越大,中奖记录的显示范围就越靠下;showCount 用来设置显示的条目,我这里是控制其显示三条;intervalId 是轮询任务的id,在这里记录下来是为了离开页面的时候要将其销毁,否则会一直占用内存。

变换方式是,追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true。最下面还有个 clearInterval(intervalId.value) 方法由于图片尺寸问题没有截到,大家直接看后面的源码。

3.中奖记录模块完整代码(背景 + 弹幕 + 中奖记录)

  1. <!-- 扭蛋机积分抽奖 -->
  2. <template>
  3. <view class="page">
  4. <!-- 扭蛋机背景图片 -->
  5. <image
  6. class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
  7. src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
  8. />
  9. <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
  10. <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
  11. <!-- 弹幕 -->
  12. <image
  13. v-for="(barrage, i) in barrages"
  14. :key="i"
  15. mode="heightFix"
  16. class="absolute left-[760rpx] h-[88rpx] z-[300]"
  17. :class="{ 'barrage-animation': barrage.startAnimation }"
  18. :style="{ top: barrage.top + 'px' }"
  19. :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
  20. />
  21. </view>
  22. <!-- 中奖记录 -->
  23. <view
  24. v-for="(item,i) in recordList"
  25. :key="i"
  26. class="left-bar ellipsis"
  27. :style="{'top': (item.top > 1624 ? 1624 : item.top) + 'rpx', 'opacity': item.show?1:0, 'z-index': item.show ? 1000: -1 }"
  28. >
  29. {{ item.phone + '获得' + item.prizeName }}
  30. </view>
  31. </view>
  32. </template>
  33. <script setup lang="ts">
  34. onLoad(async () => {
  35. startBarrageAnime() // 初始化静态弹幕效果
  36. initActivityrecord() // 初始化中奖记录效果
  37. })
  38. // 弹幕设置
  39. // 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
  40. const barrages = ref([
  41. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  42. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  43. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  44. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  45. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
  46. ])
  47. // 开始弹幕动画的函数
  48. // 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
  49. const startBarrageAnime = () => {
  50. // 遍历弹幕数组
  51. barrages.value.forEach((barrage, index) => {
  52. // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
  53. const randomTop = Math.floor(Math.random() * 200)
  54. // 计算随机延迟时间,使得弹幕不是同时出现
  55. const randomDelay = Math.random() * 10000
  56. // 设置延时函数,到达随机延迟时间后开始弹幕动画
  57. setTimeout(() => {
  58. barrage.top = randomTop // 设置弹幕的顶部位置
  59. barrage.startAnimation = true // 开始动画
  60. }, randomDelay)
  61. })
  62. }
  63. /** ******** 获取中奖记录及轮播 begin *************/
  64. interface Record {
  65. phone: string; // 用户的电话号码
  66. prizeName: string; // 奖品名称
  67. top: number; // 记录当前的顶部位置(用于动画或滚动)
  68. defaultTop: number; // 记录的默认顶部位置(初始位置)
  69. show: boolean; // 是否显示该记录
  70. }
  71. const recordList = ref<Array<Record>>([]) // 扭蛋中奖记录
  72. const showTop = 786 // 最上面那条广告的高度位置
  73. const showCount = 3 // 显示三条左侧广告消息
  74. const intervalId = ref() // 轮询任务的id,便于退出页面时销毁定时任务
  75. const initActivityrecord = async () => {
  76. // 模拟中奖数据
  77. const res = Array.from({ length: 15 }, () => ({ phone: '186****1234', prizeName: '锐星优惠券', top: 0, defaultTop: 0, show: false }))
  78. recordList.value = res.map((item: Record, i:number) => {
  79. const top = showTop + (60 * i - 1)
  80. return {
  81. ...item,
  82. top,
  83. defaultTop: top, // 保存初始高度,便于后面对总漂移数做判断
  84. show: top >= showTop && top <= showTop + (60 * showCount)
  85. }
  86. })
  87. // 如果总获奖记录不超过要显示的数目个,就不要轮播了
  88. if (recordList.value.length > (showCount + 1)) {
  89. intervalId.value = setInterval(updateRecordTop, 3000)
  90. }
  91. // 追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true
  92. // 由于下面有个判断 (recordList.value.length - 4), 所以数组长度小于四的时候就不用追加动画效果了
  93. function updateRecordTop () {
  94. recordList.value = recordList.value.map((item: Record, index) => {
  95. // 更新top值,减去60
  96. let newTop = item.top - 60
  97. // 这里做一下判断,如果轮播的高度超出了总个数,就还原高度
  98. if ((item.defaultTop - item.top) / 60 > (recordList.value.length - 4)) {
  99. newTop = item.defaultTop
  100. }
  101. // 判断新的top值是否在指定范围内,并设置show属性
  102. const newShow = newTop >= showTop && newTop <= showTop + (60 * showCount)
  103. return {
  104. ...item,
  105. top: newTop, // 更新top属性
  106. show: newShow // 更新show属性
  107. }
  108. })
  109. }
  110. }
  111. // 离开页面时销毁定时任务
  112. onBeforeUnmount(() => {
  113. clearInterval(intervalId.value)
  114. })
  115. /** ******** 获取中奖记录及轮播 end *************/
  116. </script>
  117. <style lang="scss" scoped>
  118. .page {
  119. width: 100vw;
  120. min-height: 100vh;
  121. overflow: hidden;
  122. background-color: #ff0027;
  123. }
  124. /* 弹幕平移动画 */
  125. .barrage-animation {
  126. animation-name: moveLeft; /* 指定动画名称 */
  127. animation-duration: 10s; /* 指定动画时长 */
  128. animation-play-state: running; /* 控制动画播放状态 */
  129. animation-timing-function: linear; /* 指定动画速度曲线 */
  130. animation-iteration-count: infinite; /* 指定动画循环次数 */
  131. }
  132. /* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
  133. @keyframes moveLeft {
  134. /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  135. 0% { transform: translate3d(100%, 0, 0) }
  136. 100% { transform: translate3d(-500%, 0, 0) }
  137. }
  138. /* 中奖记录轮播动画 */
  139. .left-bar{
  140. position: absolute;
  141. left: 106rpx;
  142. z-index: 100;
  143. max-width: 312rpx;
  144. padding: 8rpx 16rpx;
  145. color: #fff;
  146. font-size: 24rpx;
  147. background: rgb(0 0 0 / 0.3);
  148. border-radius: 50rpx;
  149. transition: all 0.4s ease;
  150. }
  151. .ellipsis {
  152. overflow: hidden;
  153. white-space: nowrap;
  154. text-overflow: ellipsis;
  155. }
  156. </style>

4.小球动画:基于定位的平移动画

扭蛋动画就是直接设置小球的 top 和 left 坐标。

重要:这里解释一下为什么小球变换要用定位而不是更省性能的 transform: 因为部分机型对transform支持性不好,会被过滤掉(比如ios上transform2d不生效,1加ace2对transform单项变换不生效),因此这种核心动画我选用定位来执行。

其实如果大家对机型的适配性要求不高,我是十分推荐使用 transform3d 来执行大量的变换的,会更加节省性能,如果实在无法避免使用定位来执行大量的变换,尽量将元素脱离文档流,比如设置为绝对定位。

这里由于转了gif图被抽帧所以看起来动画不够连贯,但是其实只要小球数量不是特别多,效果还是很丝滑的。

1.小球及容器 template

将扭蛋范围固定在容器 lottery-box-content 中并设置超出隐藏,这样就算小球的定位设置失误,使其超出了框体,也由于被隐藏所以没有违和感。

由于小球素材只有四种球,因此通过序列号 i%4 取余数来分配小球的外观,由于本篇文章重点都放在动画和效果的实现上,所以这里积分数据都是写死固定的,真实情况可以根据每次扭蛋来更新剩余分数。

2.小球及容器 scss

样式代码有很多,一条条来看。

首先是扭蛋机的框体和小球的样式,小球将定位单独抽出,方便后续较为直观的使用动画控制移动。

然后是球的掉落动画,在入场和扭蛋结束后执行,就是做一个竖直方向的来回震动,这个动画不是很重要,即便被ios屏蔽掉也影响不大,因此这里选用了 translateY 来执行。

最后是最为核心的小球移动动画,我这里手动测量出了小球在容器中的移动范围是 水平0-464 像素之间 ;  垂直方向 0-536 像素之间。

这也是我选择使用定位而不是transform来执行小球动画的原因,当我知道了扭蛋机边界的位置的时候,我可以很自然的模拟出更加真实的小球移动的路径(当x或y至少一个值到达了边界,就可以转换方向),而不用担心出现小球越界甚至虚空换向的问题了。

3.小球及容器 ts

ts中用来初始化小球以及衔接上面的小球动画,其实就是通过 animateClass 变量来控制 run_? 样式是否生效。

4.小球模块完整代码(背景 + 弹幕 + 中奖记录 + 小球)

  1. <!-- 扭蛋机积分抽奖 -->
  2. <template>
  3. <view class="page">
  4. <!-- 扭蛋机背景图片 -->
  5. <image
  6. class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
  7. src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
  8. />
  9. <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
  10. <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
  11. <!-- 弹幕 -->
  12. <image
  13. v-for="(barrage, i) in barrages"
  14. :key="i"
  15. mode="heightFix"
  16. class="absolute left-[760rpx] h-[88rpx] z-[300]"
  17. :class="{ 'barrage-animation': barrage.startAnimation }"
  18. :style="{ top: barrage.top + 'px' }"
  19. :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
  20. />
  21. </view>
  22. <!-- 中奖记录 -->
  23. <view
  24. v-for="(item,i) in recordList"
  25. :key="i"
  26. class="left-bar ellipsis"
  27. :style="{'top': (item.top > 1624 ? 1624 : item.top) + 'rpx', 'opacity': item.show?1:0, 'z-index': item.show ? 1000: -1 }"
  28. >
  29. {{ item.phone + '获得' + item.prizeName }}
  30. </view>
  31. <!-- 扭蛋机 -->
  32. <view class="lottery-box">
  33. <!-- 扭蛋机主框框体 -->
  34. <view class="lottery-box-content">
  35. <!-- 扭蛋机小球 -->
  36. <span
  37. v-for="(item, i) in balls"
  38. :key="i"
  39. ref="balls"
  40. class="qiu"
  41. :class="['ball_' + i%4, 'qiu_' + i, 'diaol_' + i, animateClass[i] ? 'run_' + i : '']"
  42. />
  43. </view>
  44. <!-- 扭蛋机左侧按钮 -->
  45. <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[176rpx] top-[1060rpx]" @click="handleGameGo(1)">
  46. <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">单抽</text>
  47. <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">10</text>
  48. </view>
  49. <!-- 扭蛋机右侧按钮 -->
  50. <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[418rpx] top-[1060rpx]" @click="handleGameGo(10)">
  51. <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">十连</text>
  52. <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">90</text>
  53. </view>
  54. <!-- 扭蛋机积分数据 -->
  55. <view class="absolute left-0 top-[1212rpx] h-[46rpx] w-full text-[24rpx] text-[#A13000] flex-center">
  56. 积分:10000
  57. </view>
  58. </view>
  59. </view>
  60. </template>
  61. <script setup lang="ts">
  62. onLoad(async () => {
  63. startBarrageAnime() // 初始化静态弹幕效果
  64. initActivityrecord() // 初始化中奖记录效果
  65. initBall() // 初始化扭蛋机小球
  66. })
  67. // 弹幕设置
  68. // 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
  69. const barrages = ref([
  70. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  71. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  72. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  73. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  74. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
  75. ])
  76. // 开始弹幕动画的函数
  77. // 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
  78. const startBarrageAnime = () => {
  79. // 遍历弹幕数组
  80. barrages.value.forEach((barrage, index) => {
  81. // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
  82. const randomTop = Math.floor(Math.random() * 200)
  83. // 计算随机延迟时间,使得弹幕不是同时出现
  84. const randomDelay = Math.random() * 10000
  85. // 设置延时函数,到达随机延迟时间后开始弹幕动画
  86. setTimeout(() => {
  87. barrage.top = randomTop // 设置弹幕的顶部位置
  88. barrage.startAnimation = true // 开始动画
  89. }, randomDelay)
  90. })
  91. }
  92. /** ******** 获取中奖记录及轮播 begin *************/
  93. interface Record {
  94. phone: string; // 用户的电话号码
  95. prizeName: string; // 奖品名称
  96. top: number; // 记录当前的顶部位置(用于动画或滚动)
  97. defaultTop: number; // 记录的默认顶部位置(初始位置)
  98. show: boolean; // 是否显示该记录
  99. }
  100. const recordList = ref<Array<Record>>([]) // 扭蛋中奖记录
  101. const showTop = 786 // 最上面那条广告的高度位置
  102. const showCount = 3 // 显示三条左侧广告消息
  103. const intervalId = ref() // 轮询任务的id,便于退出页面时销毁定时任务
  104. const initActivityrecord = async () => {
  105. // 模拟中奖数据
  106. const res = Array.from({ length: 15 }, () => ({ phone: '186****1234', prizeName: '锐星优惠券', top: 0, defaultTop: 0, show: false }))
  107. recordList.value = res.map((item: Record, i:number) => {
  108. const top = showTop + (60 * i - 1)
  109. return {
  110. ...item,
  111. top,
  112. defaultTop: top, // 保存初始高度,便于后面对总漂移数做判断
  113. show: top >= showTop && top <= showTop + (60 * showCount)
  114. }
  115. })
  116. // 如果总获奖记录不超过要显示的数目个,就不要轮播了
  117. if (recordList.value.length > (showCount + 1)) {
  118. intervalId.value = setInterval(updateRecordTop, 3000)
  119. }
  120. // 追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true
  121. // 由于下面有个判断 (recordList.value.length - 4), 所以数组长度小于四的时候就不用追加动画效果了
  122. function updateRecordTop () {
  123. recordList.value = recordList.value.map((item: Record, index) => {
  124. // 更新top值,减去60
  125. let newTop = item.top - 60
  126. // 这里做一下判断,如果轮播的高度超出了总个数,就还原高度
  127. if ((item.defaultTop - item.top) / 60 > (recordList.value.length - 4)) {
  128. newTop = item.defaultTop
  129. }
  130. // 判断新的top值是否在指定范围内,并设置show属性
  131. const newShow = newTop >= showTop && newTop <= showTop + (60 * showCount)
  132. return {
  133. ...item,
  134. top: newTop, // 更新top属性
  135. show: newShow // 更新show属性
  136. }
  137. })
  138. }
  139. }
  140. // 离开页面时销毁定时任务
  141. onBeforeUnmount(() => {
  142. clearInterval(intervalId.value)
  143. })
  144. /** ******** 获取中奖记录及轮播 end *************/
  145. const balls = ref<any[]>([])
  146. const animateClass = ref(Array(11).fill(false))
  147. const initBall = () => {
  148. // 将 balls 数组填充为 DOM 元素的引用
  149. balls.value = Array.from({ length: 11 }, (_, i) => i + 1).map(i => uni.createSelectorQuery().select('.qiu_' + i))
  150. }
  151. // 开始抽奖
  152. let drawLoading = false
  153. const drawType = ref(1) // 抽奖类型,1单抽 10十抽
  154. const handleGameGo = async (type: number) => {
  155. if (drawLoading) return
  156. drawType.value = type
  157. drawLoading = true
  158. beginDrawAnimation() // 开始小球乱窜动画
  159. // 这里仅关闭小球乱窜动画,接着以小球掉落动画取代
  160. setTimeout(endDrawAnimation, 1400)
  161. setTimeout(() => {
  162. drawLoading = false
  163. }, 2200)
  164. // 绘制动画
  165. function beginDrawAnimation () {
  166. animateClass.value = Array(11).fill(true)
  167. }
  168. // 结束动画
  169. function endDrawAnimation () {
  170. animateClass.value = Array(11).fill(false)
  171. }
  172. }
  173. </script>
  174. <style lang="scss" scoped>
  175. .page {
  176. width: 100vw;
  177. min-height: 100vh;
  178. overflow: hidden;
  179. background-color: #ff0027;
  180. }
  181. /* 弹幕平移动画 */
  182. .barrage-animation {
  183. animation-name: moveLeft; /* 指定动画名称 */
  184. animation-duration: 10s; /* 指定动画时长 */
  185. animation-play-state: running; /* 控制动画播放状态 */
  186. animation-timing-function: linear; /* 指定动画速度曲线 */
  187. animation-iteration-count: infinite; /* 指定动画循环次数 */
  188. }
  189. /* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
  190. @keyframes moveLeft {
  191. /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  192. 0% { transform: translate3d(100%, 0, 0) }
  193. 100% { transform: translate3d(-500%, 0, 0) }
  194. }
  195. /* 中奖记录轮播动画 */
  196. .left-bar{
  197. position: absolute;
  198. left: 106rpx;
  199. z-index: 100;
  200. max-width: 312rpx;
  201. padding: 8rpx 16rpx;
  202. color: #fff;
  203. font-size: 24rpx;
  204. background: rgb(0 0 0 / 0.3);
  205. border-radius: 50rpx;
  206. transition: all 0.4s ease;
  207. }
  208. .ellipsis {
  209. overflow: hidden;
  210. white-space: nowrap;
  211. text-overflow: ellipsis;
  212. }
  213. /* 以下都是扭蛋机相关的动画 */
  214. .lottery-box .lottery-box-content{
  215. position: absolute;
  216. top: 390rpx;
  217. left: 92rpx;
  218. z-index: 99;
  219. width: 566rpx;
  220. height: 680rpx;
  221. overflow: hidden;
  222. .qiu {
  223. position: absolute;
  224. display: block;
  225. width: 100rpx;
  226. height: 100rpx;
  227. }
  228. /* 各个球的定位 */
  229. .qiu_0 { top: 483rpx; left: 430rpx; }
  230. .qiu_1 { top: 384rpx; left: 32rpx; }
  231. .qiu_2 { top: 482rpx; left: 23rpx; }
  232. .qiu_3 { top: 580rpx; left: 10rpx; }
  233. .qiu_4 { top: 438rpx; left: 115rpx; }
  234. .qiu_5 { top: 500rpx; left: 186rpx; }
  235. .qiu_6 { top: 542rpx; left: 100rpx; }
  236. .qiu_7 { top: 458rpx; left: 278rpx; }
  237. .qiu_8 { top: 580rpx; left: 248rpx; }
  238. .qiu_9 { top: 577rpx; left: 462rpx; }
  239. .qiu_10 { top: 537rpx; left: 340rpx; }
  240. .qiu_11 { top: 480rpx; left: 333rpx; }
  241. .diaol_0{animation:dropOut 1s linear 1.4s backwards;}
  242. .diaol_0::after{animation-delay:1.3s;}
  243. .diaol_1{animation:dropOut 1s linear 0.9s backwards;}
  244. .diaol_1::after{animation-delay:0.8s;}
  245. .diaol_2{animation:dropOut 1s linear 0.6s backwards;}
  246. .diaol_2::after{animation-delay:0.5s;}
  247. .diaol_3{animation:dropOut 1s linear backwards;}
  248. .diaol_4{animation:dropOut 1s linear 1.1s backwards;}
  249. .diaol_4::after{animation-delay:1s;}
  250. .diaol_5{animation:dropOut 1s linear 0.8s backwards;}
  251. .diaol_5::after{animation-delay:0.7s;}
  252. .diaol_6{animation:dropOut 1s linear 0.4s backwards;}
  253. .diaol_6::after{animation-delay:0.3s;}
  254. .diaol_7{animation:dropOut 1s linear 0.9s backwards;}
  255. .diaol_7::after{animation-delay:0.8s;}
  256. .diaol_8{animation:dropOut 1s linear 0.6s backwards;}
  257. .diaol_8::after{animation-delay:0.5s;}
  258. .diaol_9{animation:dropOut 1s linear 1.1s backwards;}
  259. .diaol_9::after{animation-delay:1s;}
  260. .diaol_10{animation:dropOut 1s linear 0.2s backwards;}
  261. .diaol_11{animation:dropOut 1s linear 1.4s backwards;}
  262. .diaol_11::after{animation-delay:1.3s;}
  263. @keyframes dropOut {
  264. 0% { transform: translateY(-200%); opacity: 0; }
  265. 5% { transform: translateY(-200%); }
  266. 15% { transform: translateY(0); }
  267. 30% { transform: translateY(-100%); }
  268. 40% { transform: translateY(0%); }
  269. 50% { transform: translateY(-60%); }
  270. 70% { transform: translateY(0%); }
  271. 80% { transform: translateY(-30%); }
  272. 90% { transform: translateY(0%); }
  273. 95% { transform: translateY(-14%); }
  274. 97% { transform: translateY(0%); }
  275. 99% { transform: translateY(-6%); }
  276. 100% { transform: translateY(0); opacity: 1; }
  277. }
  278. .run_0 {animation:around0 1.5s linear infinite;}
  279. .run_1 {animation:around1 1.5s linear infinite;}
  280. .run_2 {animation:around2 1.5s linear infinite;}
  281. .run_3 {animation:around3 1.5s linear infinite;}
  282. .run_4 {animation:around4 1.5s linear infinite;}
  283. .run_5 {animation:around5 1.5s linear infinite;}
  284. .run_6 {animation:around6 1.5s linear infinite;}
  285. .run_7 {animation:around7 1.5s linear infinite;}
  286. .run_8 {animation:around8 1.5s linear infinite;}
  287. .run_9 {animation:around9 1.5s linear infinite;}
  288. .run_10{animation:around10 1.5s linear infinite;}
  289. .run_11{animation:around11 1.5s linear infinite;}
  290. /* 移动范围 left 0-464 ; top 0-536 */
  291. /* 这里解释一下为什么小球变换要用定位而不是更省性能transform: 因为部分机型对transform支持性不好,会被过滤掉(比如ios上transform2d不生效,1加ace2对transform单项变换不生效)因此这种关键动画使用定位来执行 */
  292. @keyframes around0 {
  293. 0% { top: 483rpx; left: 430rpx; }
  294. 20% { top: 536rpx; left: 0rpx; }
  295. 40% { top: 0rpx; left: 464rpx; }
  296. 60% { top: 200rpx; left: 0rpx; }
  297. 80% { top: 3rpx; left: 303rpx; }
  298. 100% { top: 483rpx; left: 430rpx; }
  299. }
  300. @keyframes around1 {
  301. 0% { top: 384rpx; left: 32rpx; }
  302. 16% { top: 200rpx; left: 450rpx; }
  303. 32% { top: 24rpx; left: 8rpx; }
  304. 48% { top: 500rpx; left: 106rpx; }
  305. 64% { top: 6rpx; left: 0rpx; }
  306. 82% { top: 180rpx; left: 464rpx; }
  307. 100% { top: 384rpx; left: 32rpx; }
  308. }
  309. @keyframes around2 {
  310. 0% { top: 482rpx; left: 23rpx; }
  311. 20% { top: 64rpx; left: 448rpx; }
  312. 40% { top: 520rpx; left: 16rpx; }
  313. 60% { top: 208rpx; left: 432rpx; }
  314. 80% { top: 8rpx; left: 80rpx; }
  315. 100% { top: 482rpx; left: 23rpx; }
  316. }
  317. @keyframes around3 {
  318. 0% { top: 580rpx; left: 10rpx; }
  319. 20% { top: 100rpx; left: 300rpx; }
  320. 40% { top: 20rpx; left: 10rpx; }
  321. 60% { top: 400rpx; left: 460rpx; }
  322. 80% { top: 68rpx; left: 220rpx; }
  323. 100% { top: 580rpx; left: 10rpx; }
  324. }
  325. @keyframes around4 {
  326. 0% { top: 438rpx; left: 115rpx; }
  327. 16% { top: 10rpx; left: 300rpx; }
  328. 32% { top: 530rpx; left: 30rpx; }
  329. 48% { top: 200rpx; left: 450rpx; }
  330. 64% { top: 300rpx; left: 20rpx; }
  331. 82% { top: 560rpx; left: 450rpx; }
  332. 100% { top: 438rpx; left: 115rpx; }
  333. }
  334. @keyframes around5 {
  335. 0% { top: 500rpx; left: 186rpx; }
  336. 20% { top: 200rpx; left: 50rpx; }
  337. 40% { top: 350rpx; left: 400rpx; }
  338. 60% { top: 530rpx; left: 100rpx; }
  339. 80% { top: 100rpx; left: 380rpx; }
  340. 100% { top: 500rpx; left: 186rpx; }
  341. }
  342. @keyframes around6 {
  343. 0% { top: 542rpx; left: 100rpx; }
  344. 15% { top: 300rpx; left: 300rpx; }
  345. 30% { top: 100rpx; left: 100rpx; }
  346. 45% { top: 200rpx; left: 400rpx; }
  347. 60% { top: 400rpx; left: 200rpx; }
  348. 75% { top: 100rpx; left: 450rpx; }
  349. 100% { top: 542rpx; left: 100rpx; }
  350. }
  351. @keyframes around7 {
  352. 0% { top: 458rpx; left: 278rpx; }
  353. 15% { top: 200rpx; left: 50rpx; }
  354. 35% { top: 150rpx; left: 450rpx; }
  355. 55% { top: 520rpx; left: 50rpx; }
  356. 75% { top: 250rpx; left: 450rpx; }
  357. 90% { top: 530rpx; left: 20rpx; }
  358. 100% { top: 458rpx; left: 278rpx; }
  359. }
  360. @keyframes around8 {
  361. 0% { top: 580rpx; left: 248rpx; }
  362. 20% { top: 350rpx; left: 50rpx; }
  363. 40% { top: 10rpx; left: 460rpx; }
  364. 60% { top: 536rpx; left: 460rpx; }
  365. 80% { top: 20rpx; left: 380rpx; }
  366. 100% { top: 580rpx; left: 248rpx; }
  367. }
  368. @keyframes around9 {
  369. 0% { top: 577rpx; left: 462rpx; }
  370. 12.5% { top: 400rpx; left: 300rpx; }
  371. 25% { top: 450rpx; left: 500rpx; }
  372. 37.5% { top: 350rpx; left: 200rpx; }
  373. 50% { top: 250rpx; left: 450rpx; }
  374. 62.5% { top: 400rpx; left: 150rpx; }
  375. 75% { top: 150rpx; left: 350rpx; }
  376. 87.5% { top: 500rpx; left: 250rpx; }
  377. 100% { top: 577rpx; left: 462rpx; }
  378. }
  379. @keyframes around10 {
  380. 0% { top: 537rpx; left: 340rpx; }
  381. 15% { top: 400rpx; left: 150rpx; }
  382. 30% { top: 350rpx; left: 450rpx; }
  383. 50% { top: 50rpx; left: 50rpx; }
  384. 70% { top: 450rpx; left: 400rpx; }
  385. 85% { top: 550rpx; left: 120rpx; }
  386. 100% { top: 537rpx; left: 340rpx; }
  387. }
  388. @keyframes around11 {
  389. 0% { top: 480rpx; left: 333rpx; }
  390. 16% { top: 350rpx; left: 464rpx; }
  391. 33% { top: 400rpx; left: 200rpx; }
  392. 50% { top: 200rpx; left: 400rpx; }
  393. 66% { top: 300rpx; left: 100rpx; }
  394. 83% { top: 100rpx; left: 300rpx; }
  395. 100% { top: 480rpx; left: 333rpx; }
  396. }
  397. .ball_0{
  398. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball0.png');
  399. background-repeat: no-repeat;
  400. background-size: 100%;
  401. }
  402. .ball_1{
  403. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball1.png');
  404. background-repeat: no-repeat;
  405. background-size: 100%;
  406. }
  407. .ball_2{
  408. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball2.png');
  409. background-repeat: no-repeat;
  410. background-size: 100%;
  411. }
  412. .ball_3{
  413. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball3.png');
  414. background-repeat: no-repeat;
  415. background-size: 100%;
  416. }
  417. }
  418. </style>

5.标题箭头,基于关键帧的叠三角指向效果

这里通过控制两个三角形透明度动画的关键帧的先后顺序,达到一种箭头由外指向内的视觉效果。

这里动画很快,转成gif图看动画也不明显,就不贴图了,可以根据视频里看出这里标题的动画效果。

这一块不是很重要,属于锦上添花的功能,源码就不单独贴出了,放在后面一起给出吧。

6.奖品动画,渐变背景添加扫光

最重要的地方来了,你以为的扭蛋机核心效果:小球乱窜效果;实际的扭蛋机核心效果:奖品弹窗效果。

扭蛋中的小球平移效果其实并不是很重要,因为用户视觉上只会停留很短的时间(一秒左右)就会被奖品弹窗给覆盖,因此奖品弹窗效果才是最重要的模块(用户视觉停留最长)。

这里奖品弹窗使用五层效果叠加,即 遮罩+底图+奖品图+蒙版+扫光层 的效果,因此要注意 z-index 的层级设置。

单抽和十连抽效果不同,先分开讲。

单抽效果比较简单,只需要添加一层扫光效果即可。

扫光的效果是,先绘制一条斜向的渐变div,接着以纵向或横向的方式平移即可。通过渐变让一道白光一闪而过,一开始使用的斜向位移,后来发觉只要背景是斜的,哪怕只做一个维度的平移而形成的视觉效果也像斜向扫描,因此去掉水平渐变。

接下来是十连抽的效果图,有了单抽效果图做铺垫,十连抽就很好理解了,只不过增加了十个扭蛋之间的依次展现的效果。

十连抽的核心在于,灵活设置不同的 animation-delay 属性,让多个商品之间的动画有错落感。

十连抽scss:

十连抽中有个翻转动画,如果是在 h5 端就很容易实现,直接使用 backface-visibility: hidden 让div背部的元素隐藏,可惜这里是小程序,backface-visibility: hidden 不生效,只能通过 opacity 来让翻转到背面的元素隐藏。

在ts中额外设置十连抽商品弹窗的位置,这里单独拿出来的 delay 属性是专门控制扫光动画的,渐现动画和翻转动画都是根据商品下标来按顺序来的,但是扫光动画是从左下角扫到右上角,所以独立设置一下delay属性。

效果如下:

最后的扫光动画一定要加上,它是立体感的灵魂。

7.案例源码

注释写的很详细,可能有些同学没有在 tailwindcss 环境下,不过问题不大,那些模板中的样式都能望文知义,很好理解。

  1. <!-- 扭蛋机积分抽奖 -->
  2. <template>
  3. <view class="page">
  4. <!-- 扭蛋机背景图片 -->
  5. <image
  6. class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
  7. src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
  8. />
  9. <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
  10. <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
  11. <!-- 弹幕 -->
  12. <image
  13. v-for="(barrage, i) in barrages"
  14. :key="i"
  15. mode="heightFix"
  16. class="absolute left-[760rpx] h-[88rpx] z-[300]"
  17. :class="{ 'barrage-animation': barrage.startAnimation }"
  18. :style="{ top: barrage.top + 'px' }"
  19. :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
  20. />
  21. </view>
  22. <!-- 中奖记录 -->
  23. <view
  24. v-for="(item,i) in recordList"
  25. :key="i"
  26. class="left-bar ellipsis"
  27. :style="{'top': (item.top > 1624 ? 1624 : item.top) + 'rpx', 'opacity': item.show?1:0, 'z-index': item.show ? 1000: -1 }"
  28. >
  29. {{ item.phone + '获得' + item.prizeName }}
  30. </view>
  31. <!-- 扭蛋机 -->
  32. <view class="lottery-box">
  33. <!-- 扭蛋机主框框体 -->
  34. <view class="lottery-box-content">
  35. <!-- 扭蛋机小球 -->
  36. <span
  37. v-for="(item, i) in balls"
  38. :key="i"
  39. ref="balls"
  40. class="qiu"
  41. :class="['ball_' + i%4, 'qiu_' + i, 'diaol_' + i, animateClass[i] ? 'run_' + i : '']"
  42. />
  43. </view>
  44. <!-- 扭蛋机左侧按钮 -->
  45. <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[176rpx] top-[1060rpx]" @click="handleGameGo(1)">
  46. <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">单抽</text>
  47. <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">10</text>
  48. </view>
  49. <!-- 扭蛋机右侧按钮 -->
  50. <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[418rpx] top-[1060rpx]" @click="handleGameGo(10)">
  51. <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">十连</text>
  52. <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">90</text>
  53. </view>
  54. <!-- 扭蛋机积分数据 -->
  55. <view class="absolute left-0 top-[1212rpx] h-[46rpx] w-full text-[24rpx] text-[#A13000] flex-center">
  56. 积分:10000
  57. </view>
  58. </view>
  59. <view class="h-[1342rpx]" /><!-- 占位至扭蛋机末端,恢复文档流 -->
  60. <!-- 奖品展示标题,这里有个比较弱的动画效果,两边的箭头会往中间的文字部分指向 -->
  61. <view class="my-[24rpx] flex-center">
  62. <!-- 左外侧箭头,会先亮起 -->
  63. <view class="triangle outside" style="transform: rotate(90deg);" />
  64. <!-- 左内侧箭头,稍微后亮 -->
  65. <view class="triangle inside" style="transform: rotate(90deg);" />
  66. <view class="mx-[16rpx] text-[36rpx] text-[#ffffff] font-medium z-10">
  67. 奖品展示
  68. </view>
  69. <!-- 右内侧箭头,后亮 -->
  70. <view class="triangle inside" style="transform: rotate(-90deg);" />
  71. <!-- 右外侧箭头,先亮起来 -->
  72. <view class="triangle outside" style="transform: rotate(-90deg);" />
  73. </view>
  74. <!-- 奖品展示区 -->
  75. <view class="relative w-[702rpx] mx-[24rpx] py-[36rpx] pl-[42rpx] flex flex-wrap bg-[#fff] z-10 rounded-[16rpx]">
  76. <view v-for="item in [1,2,3,4,5,6,7,8,9]" :key="item" class="mb-[24rpx] w-[190rpx] mr-[24rpx] h-[234rpx]">
  77. <!-- 奖品封面 -->
  78. <image class="rounded-[16rpx] bg-[#FF0000] w-[190rpx] h-[162rpx]" mode="aspectFill" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/goods.jpg" />
  79. <!-- 奖品名称 -->
  80. <view class="my-[8rpx] ellipsis w-[190rpx] break-all text-[#333] text-[24rpx]">
  81. 锐星优惠券
  82. </view>
  83. <view class="text-[24rpx] text-[#FF0000] line-through">
  84. ¥5.5
  85. </view>
  86. </view>
  87. </view>
  88. <!-- 占位盒,避免最底部贴紧边缘,一般都是用padding-bottom占位,这里单独用一个盒子是为了显眼,因为本页绝对定位的元素较多 -->
  89. <view class="w-full h-[64rpx]" />
  90. <!-- 遮罩与弹窗 -->
  91. <view v-if="dialogObj.show" class="loaded fixed top-0 left-0 w-full h-[100vh] bg-[rgba(0,0,0,0.7)] z-[9999]">
  92. <!-- 关闭按钮 -->
  93. <!-- 给关闭按钮加上一个更广的事件触发范围,不然用户不容易点到 -->
  94. <view class="absolute w-[80rpx] h-[80rpx] top-[330rpx] right-[62rpx] z-[10020] flex-center" @click="closeDialog">
  95. <image class="w-[42rpx] h-[42rpx]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/closed.png" />
  96. </view>
  97. <!-- 单抽显示奖品 -->
  98. <template v-if="drawType===1">
  99. <!-- 商品名称 -->
  100. <view class="absolute flicker w-full h-[40rpx] px-[32rpx] flex-center top-[500rpx] text-[26rpx] text-[#fff] font-semibold">
  101. —— 锐星优惠券 ——
  102. </view>
  103. <!-- 底部背景 -->
  104. <image class="absolute w-[750rpx] h-[605rpx] top-[400rpx] left-0 z-[10000]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/result1.png" />
  105. <!-- 奖品图片 -->
  106. <image v-if="dialogObj.show" class="absolute rounded-full w-[430rpx] h-[430rpx] left-[160rpx] top-[568rpx] z-[10005]" mode="aspectFill" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/goods.jpg" />
  107. <!-- 上层遮罩 -->
  108. <image class="absolute w-[750rpx] h-[444rpx] top-[560rpx] left-0 z-[10010]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/result2.png" />
  109. <!-- 白光扫描层,增加一道扫描动画使商品效果更为立体 -->
  110. <view v-if="dialogObj.show" class="rounded-full overflow-hidden absolute w-[430rpx] h-[430rpx] left-[160rpx] top-[568rpx] z-[10020]">
  111. <!-- 扫描主体 -->
  112. <view class="scan-overlay" />
  113. </view>
  114. </template>
  115. <!-- 十连展示商品 -->
  116. <template v-if="drawType===10">
  117. <!-- 顶部恭喜获得 -->
  118. <image class="absolute w-[443rpx] h-[98rpx] top-[400rpx] left-[153rpx] z-[10000]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/result3.png" />
  119. <!-- 每一个中奖盒子 -->
  120. <view
  121. v-for="(item,i) in tenMap"
  122. :key="i"
  123. :style="{'left': item.left, 'top': item.top, 'animation-delay': (i*0.05+0.2) + 's'}"
  124. class="absolute w-[172rpx] h-[172rpx] loaded"
  125. >
  126. <!-- 扭蛋内容,正面底图,背面商品 -->
  127. <view class="toy-box toy" :style="{'animation-delay': (i*0.05+0.6) + 's'}">
  128. <!-- 商品图 -->
  129. <div class="front" :style="{'animation-delay': (i*0.05+0.6) + 's'}">
  130. <image src="https://gitee.com/jingkunxu/img/raw/master/note/blog/goods.jpg" alt="Back" />
  131. </div>
  132. <!-- 背景图 -->
  133. <div class="back" :style="{'animation-delay': (i*0.05+0.6) + 's'}">
  134. <image src="https://gitee.com/jingkunxu/img/raw/master/note/blog/result5.png" alt="Front" />
  135. </div>
  136. </view>
  137. <!-- 白光扫描层,增加一道扫描动画使商品效果更为立体 -->
  138. <view class="toy-box absolute z-[10020]">
  139. <!-- 扫描主体 -->
  140. <view class="scan-overlay-ten" :style="{'animation-delay': (item.delay*0.1 + 1.6) + 's'}" />
  141. </view>
  142. <!-- 商品遮罩 -->
  143. <image class="absolute w-full h-[142rpx] top-0 left-0 z-[10010]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/result4.png" />
  144. <view class="mt-[12rpx] w-full h-[60rpx] text-[22rpx] text-[#fff] text-line-2 font-semibold text-center">
  145. 锐星优惠券
  146. </view>
  147. </view>
  148. </template>
  149. <!-- 左侧按钮 -->
  150. <button class="absolute w-[226rpx] h-[84.0rpx] left-[116rpx] z-[10020] flex-center plain" :style="{top: drawType===10? '1170rpx': '958rpx'}" plain open-type="share">
  151. <!-- 左侧按钮背景图 -->
  152. <image class="absolute w-full h-full insert-0 z-[10020]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/left.png" />
  153. <!-- 左侧按钮名 -->
  154. <text class="text-[40rpx] text-[#fff] font-bold z-[10030]">分享</text>
  155. </button>
  156. <!-- 右侧按钮 -->
  157. <view class="absolute w-[226rpx] h-[84.0rpx] left-[408rpx] z-[10020] flex-center" :style="{top: drawType===10? '1170rpx': '958rpx'}" @click="onceAgain">
  158. <!-- 右侧按钮背景图 -->
  159. <image class="absolute w-full h-full insert-0 z-[10020]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/right.png" />
  160. <!-- 右侧按钮名 -->
  161. <text class="text-[40rpx] text-[#fff] font-bold z-[10030]">{{ drawType===1 ? '再抽一次':'再抽十次' }}</text>
  162. </view>
  163. </view>
  164. </view>
  165. </template>
  166. <script setup lang="ts">
  167. onLoad(async () => {
  168. startBarrageAnime() // 初始化静态弹幕效果
  169. initActivityrecord() // 初始化中奖记录效果
  170. initBall() // 初始化扭蛋机小球
  171. })
  172. // 弹幕设置
  173. // 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
  174. const barrages = ref([
  175. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  176. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  177. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  178. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  179. { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
  180. ])
  181. // 开始弹幕动画的函数
  182. // 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
  183. const startBarrageAnime = () => {
  184. // 遍历弹幕数组
  185. barrages.value.forEach((barrage, index) => {
  186. // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
  187. const randomTop = Math.floor(Math.random() * 200)
  188. // 计算随机延迟时间,使得弹幕不是同时出现
  189. const randomDelay = Math.random() * 10000
  190. // 设置延时函数,到达随机延迟时间后开始弹幕动画
  191. setTimeout(() => {
  192. barrage.top = randomTop // 设置弹幕的顶部位置
  193. barrage.startAnimation = true // 开始动画
  194. }, randomDelay)
  195. })
  196. }
  197. /** ******** 获取中奖记录及轮播 begin *************/
  198. interface Record {
  199. phone: string; // 用户的电话号码
  200. prizeName: string; // 奖品名称
  201. top: number; // 记录当前的顶部位置(用于动画或滚动)
  202. defaultTop: number; // 记录的默认顶部位置(初始位置)
  203. show: boolean; // 是否显示该记录
  204. }
  205. const recordList = ref<Array<Record>>([]) // 扭蛋中奖记录
  206. const showTop = 786 // 最上面那条广告的高度位置
  207. const showCount = 3 // 显示三条左侧广告消息
  208. const intervalId = ref() // 轮询任务的id,便于退出页面时销毁定时任务
  209. const initActivityrecord = async () => {
  210. // 模拟中奖数据
  211. const res = Array.from({ length: 15 }, () => ({ phone: '186****1234', prizeName: '锐星优惠券', top: 0, defaultTop: 0, show: false }))
  212. recordList.value = res.map((item: Record, i:number) => {
  213. const top = showTop + (60 * i - 1)
  214. return {
  215. ...item,
  216. top,
  217. defaultTop: top, // 保存初始高度,便于后面对总漂移数做判断
  218. show: top >= showTop && top <= showTop + (60 * showCount)
  219. }
  220. })
  221. // 如果总获奖记录不超过要显示的数目个,就不要轮播了
  222. if (recordList.value.length > (showCount + 1)) {
  223. intervalId.value = setInterval(updateRecordTop, 3000)
  224. }
  225. // 追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true
  226. // 由于下面有个判断 (recordList.value.length - 4), 所以数组长度小于四的时候就不用追加动画效果了
  227. function updateRecordTop () {
  228. recordList.value = recordList.value.map((item: Record, index) => {
  229. // 更新top值,减去60
  230. let newTop = item.top - 60
  231. // 这里做一下判断,如果轮播的高度超出了总个数,就还原高度
  232. if ((item.defaultTop - item.top) / 60 > (recordList.value.length - 4)) {
  233. newTop = item.defaultTop
  234. }
  235. // 判断新的top值是否在指定范围内,并设置show属性
  236. const newShow = newTop >= showTop && newTop <= showTop + (60 * showCount)
  237. return {
  238. ...item,
  239. top: newTop, // 更新top属性
  240. show: newShow // 更新show属性
  241. }
  242. })
  243. }
  244. }
  245. // 离开页面时销毁定时任务
  246. onBeforeUnmount(() => {
  247. clearInterval(intervalId.value)
  248. })
  249. /** ******** 获取中奖记录及轮播 end *************/
  250. const balls = ref<any[]>([])
  251. const animateClass = ref(Array(11).fill(false))
  252. const initBall = () => {
  253. // 将 balls 数组填充为 DOM 元素的引用
  254. balls.value = Array.from({ length: 11 }, (_, i) => i + 1).map(i => uni.createSelectorQuery().select('.qiu_' + i))
  255. }
  256. // 开始抽奖
  257. let drawLoading = false
  258. const drawType = ref(1) // 抽奖类型,1单抽 10十抽
  259. const handleGameGo = async (type: number) => {
  260. if (drawLoading) return
  261. drawType.value = type
  262. drawLoading = true
  263. beginDrawAnimation() // 开始小球乱窜动画
  264. // 这里仅关闭小球乱窜动画,接着以小球掉落动画取代
  265. setTimeout(endDrawAnimation, 1400)
  266. setTimeout(() => {
  267. drawLoading = false
  268. dialogObj.show = true // 显示遮罩层
  269. }, 2200)
  270. // 绘制动画
  271. function beginDrawAnimation () {
  272. animateClass.value = Array(11).fill(true)
  273. }
  274. // 结束动画
  275. function endDrawAnimation () {
  276. animateClass.value = Array(11).fill(false)
  277. }
  278. }
  279. // 弹窗对象
  280. const dialogObj = reactive({
  281. show: false, // 是否显示弹窗
  282. awardList: [{ prizeName: '', images: [{ narrowUrl: '' }] }] // 弹窗内容
  283. })
  284. const closeDialog = () => {
  285. dialogObj.show = false
  286. }
  287. // 十连弹窗的位置设置
  288. const tenMap = [
  289. { left: '106rpx', top: '500rpx', delay: 2 },
  290. { left: '289rpx', top: '500rpx', delay: 3 },
  291. { left: '470rpx', top: '500rpx', delay: 4 },
  292. { left: '25rpx', top: '710rpx', delay: 1 },
  293. { left: '208rpx', top: '710rpx', delay: 2 },
  294. { left: '391rpx', top: '710rpx', delay: 3 },
  295. { left: '574rpx', top: '710rpx', delay: 4 },
  296. { left: '106rpx', top: '920rpx', delay: 1 },
  297. { left: '289rpx', top: '920rpx', delay: 2 },
  298. { left: '470rpx', top: '920rpx', delay: 3 }
  299. ]
  300. // 抽奖结束,再抽一次
  301. const onceAgain = () => {
  302. closeDialog()
  303. handleGameGo(drawType.value)
  304. }
  305. </script>
  306. <style lang="scss" scoped>
  307. .page {
  308. width: 100vw;
  309. min-height: 100vh;
  310. overflow: hidden;
  311. background-color: #ff0027;
  312. }
  313. /* 弹幕平移动画 */
  314. .barrage-animation {
  315. animation-name: moveLeft; /* 指定动画名称 */
  316. animation-duration: 10s; /* 指定动画时长 */
  317. animation-play-state: running; /* 控制动画播放状态 */
  318. animation-timing-function: linear; /* 指定动画速度曲线 */
  319. animation-iteration-count: infinite; /* 指定动画循环次数 */
  320. }
  321. /* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
  322. @keyframes moveLeft {
  323. /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  324. 0% { transform: translate3d(100%, 0, 0) }
  325. 100% { transform: translate3d(-500%, 0, 0) }
  326. }
  327. /* 中奖记录轮播动画 */
  328. .left-bar{
  329. position: absolute;
  330. left: 106rpx;
  331. z-index: 100;
  332. max-width: 312rpx;
  333. padding: 8rpx 16rpx;
  334. color: #fff;
  335. font-size: 24rpx;
  336. background: rgb(0 0 0 / 0.3);
  337. border-radius: 50rpx;
  338. transition: all 0.4s ease;
  339. }
  340. .ellipsis {
  341. overflow: hidden;
  342. white-space: nowrap;
  343. text-overflow: ellipsis;
  344. }
  345. /* 缓入动画,百搭 */
  346. .loaded{
  347. opacity: 0;
  348. animation: show .5s ease forwards;
  349. @keyframes show{
  350. from{ opacity: 0; }
  351. to{ opacity: 1; }
  352. }
  353. }
  354. /**
  355. * 三角标题动画
  356. * 动画核心是让外侧的三角形先开始亮,极短的时间内内侧三角形再亮,造成一种箭头指向的视觉效果
  357. */
  358. .triangle {
  359. position: relative;
  360. width: 0;
  361. height: 0;
  362. border-color: transparent transparent #fff;
  363. border-style: solid;
  364. border-width: 0 16rpx 28rpx;
  365. &.inside {
  366. animation: Inside 2s infinite;
  367. @keyframes Inside {
  368. 0% { opacity: 0.7; }
  369. 27% { opacity: 0.7; }
  370. 57% { opacity: 1; }
  371. 80% { opacity: 1; }
  372. 100% { opacity: 0.7; }
  373. }
  374. }
  375. &.outside {
  376. animation: Outside 2s infinite;
  377. @keyframes Outside {
  378. 0% { opacity: 0.7; }
  379. 20% { opacity: 0.7; }
  380. 50% { opacity: 1; }
  381. 80% { opacity: 1; }
  382. 100% { opacity: 0.7; }
  383. }
  384. }
  385. }
  386. /**
  387. * 商品扫描动画
  388. * 通过渐变让一道白光一闪而过
  389. * 一开始使用的斜向位移,后来发觉只要背景是斜的,哪怕只做一个维度的平移而形成的视觉效果也像斜向扫描,因此去掉水平渐变
  390. */
  391. .scan-overlay {
  392. position: absolute;
  393. top: -400rpx;
  394. left: -100rpx;
  395. width: 550rpx;
  396. height: 550rpx;
  397. background: linear-gradient(
  398. to right top,
  399. transparent 0%,
  400. transparent 40%, /* 开始渐变前的透明区域 */
  401. rgb(255 255 255 / 0.1) 50%, /* 渐变开始,较低的透明度 */
  402. rgb(255 255 255 / 0.6) 60%, /* 渐变中间,较高的透明度 */
  403. rgb(255 255 255 / 0.1) 70%, /* 渐变结束,较低的透明度 */
  404. transparent 80%, /* 结束渐变后的透明区域 */
  405. transparent 100%
  406. );
  407. /* 设置足够长的时间让效果看起来是轮次执行的感觉 */
  408. animation: scan 6s 0.6s linear infinite;
  409. /* 下面这个关键帧的时间不要改广了,不然像是乱窜 */
  410. @keyframes scan{
  411. 0%{ top: 600rpx; }
  412. 10%{ top: -400rpx; }
  413. 100%{ top: -400rpx; }
  414. }
  415. }
  416. // 给商品名称一个扫描同步的闪动动画
  417. .flicker{
  418. opacity: 0.8;
  419. // 注意如果要改这里的时间,上面扫描的时间最好也要同步更改
  420. animation: flicker 6s 1s linear infinite;
  421. @keyframes flicker{
  422. 0%{ opacity: 0.8; }
  423. 14%{ opacity: 0.8; }
  424. 20%{ opacity: 1; }
  425. 36%{ opacity: 1; }
  426. 42%{ opacity: 0.8; }
  427. 100%{ opacity: 0.8 }
  428. }
  429. }
  430. // 十连扫光动画
  431. .scan-overlay-ten {
  432. position: absolute;
  433. top: 200rpx;
  434. left: -70rpx;
  435. width: 300rpx;
  436. height: 330rpx;
  437. background: linear-gradient(
  438. to right top,
  439. transparent 0%,
  440. transparent 40%, /* 开始渐变前的透明区域 */
  441. rgb(255 255 255 / 0.1) 50%, /* 渐变开始,较低的透明度 */
  442. rgb(255 255 255 / 0.6) 60%, /* 渐变中间,较高的透明度 */
  443. rgb(255 255 255 / 0.1) 70%, /* 渐变结束,较低的透明度 */
  444. transparent 80%, /* 结束渐变后的透明区域 */
  445. transparent 100%
  446. );
  447. /* 设置足够长的时间让效果看起来是轮次执行的感觉 */
  448. animation: scan-ten 6s linear infinite;
  449. /* 下面这个关键帧的时间不要改广了,不然像是乱窜 */
  450. @keyframes scan-ten{
  451. 0%{ top: 200rpx; }
  452. 15%{ top: -300rpx; }
  453. 100%{ top: -300rpx; }
  454. }
  455. }
  456. /* 十连扭蛋的奖品 */
  457. .toy-box{
  458. left: 22rpx;
  459. top: 4rpx;
  460. width: 136rpx;
  461. height: 136rpx;
  462. border-radius: 200rpx;
  463. overflow: hidden;
  464. }
  465. .toy{
  466. position: relative;
  467. perspective: 1000px;
  468. transform: rotateY(180deg);
  469. animation: flipAnimation 1s 1s forwards; /* 动画名称,持续时间,延迟时间,填充模式 */
  470. .front, .back {
  471. position: absolute;
  472. width: 100%;
  473. height: 100%;
  474. }
  475. .front image, .back image {
  476. width: 100%;
  477. height: 100%;
  478. }
  479. /* 小程序中大部分机型不支持 backface-visibility: hidden,所以这使用opacity来控制显隐 */
  480. .front {
  481. opacity: 0;
  482. animation: o-1 1s 1s forwards;
  483. @keyframes o-1 {
  484. from {opacity: 0;}
  485. to { opacity: 1; }
  486. }
  487. }
  488. .back {
  489. animation: o-0 1s 1s forwards;
  490. /* transform: rotateY(360deg); */
  491. @keyframes o-0 {
  492. from {opacity: 1;}
  493. to { opacity: 0; }
  494. }
  495. }
  496. @keyframes flipAnimation {
  497. from {transform: rotateY(180deg);}
  498. to {transform: rotateY(360deg);}
  499. }
  500. }
  501. /* 以下都是扭蛋机相关的动画 */
  502. .lottery-box .lottery-box-content{
  503. position: absolute;
  504. top: 390rpx;
  505. left: 92rpx;
  506. z-index: 99;
  507. width: 566rpx;
  508. height: 680rpx;
  509. overflow: hidden;
  510. .qiu {
  511. position: absolute;
  512. display: block;
  513. width: 100rpx;
  514. height: 100rpx;
  515. }
  516. /* 各个球的定位 */
  517. .qiu_0 { top: 483rpx; left: 430rpx; }
  518. .qiu_1 { top: 384rpx; left: 32rpx; }
  519. .qiu_2 { top: 482rpx; left: 23rpx; }
  520. .qiu_3 { top: 580rpx; left: 10rpx; }
  521. .qiu_4 { top: 438rpx; left: 115rpx; }
  522. .qiu_5 { top: 500rpx; left: 186rpx; }
  523. .qiu_6 { top: 542rpx; left: 100rpx; }
  524. .qiu_7 { top: 458rpx; left: 278rpx; }
  525. .qiu_8 { top: 580rpx; left: 248rpx; }
  526. .qiu_9 { top: 577rpx; left: 462rpx; }
  527. .qiu_10 { top: 537rpx; left: 340rpx; }
  528. .qiu_11 { top: 480rpx; left: 333rpx; }
  529. .diaol_0{animation:dropOut 1s linear 1.4s backwards;}
  530. .diaol_0::after{animation-delay:1.3s;}
  531. .diaol_1{animation:dropOut 1s linear 0.9s backwards;}
  532. .diaol_1::after{animation-delay:0.8s;}
  533. .diaol_2{animation:dropOut 1s linear 0.6s backwards;}
  534. .diaol_2::after{animation-delay:0.5s;}
  535. .diaol_3{animation:dropOut 1s linear backwards;}
  536. .diaol_4{animation:dropOut 1s linear 1.1s backwards;}
  537. .diaol_4::after{animation-delay:1s;}
  538. .diaol_5{animation:dropOut 1s linear 0.8s backwards;}
  539. .diaol_5::after{animation-delay:0.7s;}
  540. .diaol_6{animation:dropOut 1s linear 0.4s backwards;}
  541. .diaol_6::after{animation-delay:0.3s;}
  542. .diaol_7{animation:dropOut 1s linear 0.9s backwards;}
  543. .diaol_7::after{animation-delay:0.8s;}
  544. .diaol_8{animation:dropOut 1s linear 0.6s backwards;}
  545. .diaol_8::after{animation-delay:0.5s;}
  546. .diaol_9{animation:dropOut 1s linear 1.1s backwards;}
  547. .diaol_9::after{animation-delay:1s;}
  548. .diaol_10{animation:dropOut 1s linear 0.2s backwards;}
  549. .diaol_11{animation:dropOut 1s linear 1.4s backwards;}
  550. .diaol_11::after{animation-delay:1.3s;}
  551. @keyframes dropOut {
  552. 0% { transform: translateY(-200%); opacity: 0; }
  553. 5% { transform: translateY(-200%); }
  554. 15% { transform: translateY(0); }
  555. 30% { transform: translateY(-100%); }
  556. 40% { transform: translateY(0%); }
  557. 50% { transform: translateY(-60%); }
  558. 70% { transform: translateY(0%); }
  559. 80% { transform: translateY(-30%); }
  560. 90% { transform: translateY(0%); }
  561. 95% { transform: translateY(-14%); }
  562. 97% { transform: translateY(0%); }
  563. 99% { transform: translateY(-6%); }
  564. 100% { transform: translateY(0); opacity: 1; }
  565. }
  566. .run_0 {animation:around0 1.5s linear infinite;}
  567. .run_1 {animation:around1 1.5s linear infinite;}
  568. .run_2 {animation:around2 1.5s linear infinite;}
  569. .run_3 {animation:around3 1.5s linear infinite;}
  570. .run_4 {animation:around4 1.5s linear infinite;}
  571. .run_5 {animation:around5 1.5s linear infinite;}
  572. .run_6 {animation:around6 1.5s linear infinite;}
  573. .run_7 {animation:around7 1.5s linear infinite;}
  574. .run_8 {animation:around8 1.5s linear infinite;}
  575. .run_9 {animation:around9 1.5s linear infinite;}
  576. .run_10{animation:around10 1.5s linear infinite;}
  577. .run_11{animation:around11 1.5s linear infinite;}
  578. /* 移动范围 left 0-464 ; top 0-536 */
  579. /* 这里解释一下为什么小球变换要用定位而不是更省性能transform: 因为部分机型对transform支持性不好,会被过滤掉(比如ios上transform2d不生效,1加ace2对transform单项变换不生效)因此这种关键动画使用定位来执行 */
  580. @keyframes around0 {
  581. 0% { top: 483rpx; left: 430rpx; }
  582. 20% { top: 536rpx; left: 0rpx; }
  583. 40% { top: 0rpx; left: 464rpx; }
  584. 60% { top: 200rpx; left: 0rpx; }
  585. 80% { top: 3rpx; left: 303rpx; }
  586. 100% { top: 483rpx; left: 430rpx; }
  587. }
  588. @keyframes around1 {
  589. 0% { top: 384rpx; left: 32rpx; }
  590. 16% { top: 200rpx; left: 450rpx; }
  591. 32% { top: 24rpx; left: 8rpx; }
  592. 48% { top: 500rpx; left: 106rpx; }
  593. 64% { top: 6rpx; left: 0rpx; }
  594. 82% { top: 180rpx; left: 464rpx; }
  595. 100% { top: 384rpx; left: 32rpx; }
  596. }
  597. @keyframes around2 {
  598. 0% { top: 482rpx; left: 23rpx; }
  599. 20% { top: 64rpx; left: 448rpx; }
  600. 40% { top: 520rpx; left: 16rpx; }
  601. 60% { top: 208rpx; left: 432rpx; }
  602. 80% { top: 8rpx; left: 80rpx; }
  603. 100% { top: 482rpx; left: 23rpx; }
  604. }
  605. @keyframes around3 {
  606. 0% { top: 580rpx; left: 10rpx; }
  607. 20% { top: 100rpx; left: 300rpx; }
  608. 40% { top: 20rpx; left: 10rpx; }
  609. 60% { top: 400rpx; left: 460rpx; }
  610. 80% { top: 68rpx; left: 220rpx; }
  611. 100% { top: 580rpx; left: 10rpx; }
  612. }
  613. @keyframes around4 {
  614. 0% { top: 438rpx; left: 115rpx; }
  615. 16% { top: 10rpx; left: 300rpx; }
  616. 32% { top: 530rpx; left: 30rpx; }
  617. 48% { top: 200rpx; left: 450rpx; }
  618. 64% { top: 300rpx; left: 20rpx; }
  619. 82% { top: 560rpx; left: 450rpx; }
  620. 100% { top: 438rpx; left: 115rpx; }
  621. }
  622. @keyframes around5 {
  623. 0% { top: 500rpx; left: 186rpx; }
  624. 20% { top: 200rpx; left: 50rpx; }
  625. 40% { top: 350rpx; left: 400rpx; }
  626. 60% { top: 530rpx; left: 100rpx; }
  627. 80% { top: 100rpx; left: 380rpx; }
  628. 100% { top: 500rpx; left: 186rpx; }
  629. }
  630. @keyframes around6 {
  631. 0% { top: 542rpx; left: 100rpx; }
  632. 15% { top: 300rpx; left: 300rpx; }
  633. 30% { top: 100rpx; left: 100rpx; }
  634. 45% { top: 200rpx; left: 400rpx; }
  635. 60% { top: 400rpx; left: 200rpx; }
  636. 75% { top: 100rpx; left: 450rpx; }
  637. 100% { top: 542rpx; left: 100rpx; }
  638. }
  639. @keyframes around7 {
  640. 0% { top: 458rpx; left: 278rpx; }
  641. 15% { top: 200rpx; left: 50rpx; }
  642. 35% { top: 150rpx; left: 450rpx; }
  643. 55% { top: 520rpx; left: 50rpx; }
  644. 75% { top: 250rpx; left: 450rpx; }
  645. 90% { top: 530rpx; left: 20rpx; }
  646. 100% { top: 458rpx; left: 278rpx; }
  647. }
  648. @keyframes around8 {
  649. 0% { top: 580rpx; left: 248rpx; }
  650. 20% { top: 350rpx; left: 50rpx; }
  651. 40% { top: 10rpx; left: 460rpx; }
  652. 60% { top: 536rpx; left: 460rpx; }
  653. 80% { top: 20rpx; left: 380rpx; }
  654. 100% { top: 580rpx; left: 248rpx; }
  655. }
  656. @keyframes around9 {
  657. 0% { top: 577rpx; left: 462rpx; }
  658. 12.5% { top: 400rpx; left: 300rpx; }
  659. 25% { top: 450rpx; left: 500rpx; }
  660. 37.5% { top: 350rpx; left: 200rpx; }
  661. 50% { top: 250rpx; left: 450rpx; }
  662. 62.5% { top: 400rpx; left: 150rpx; }
  663. 75% { top: 150rpx; left: 350rpx; }
  664. 87.5% { top: 500rpx; left: 250rpx; }
  665. 100% { top: 577rpx; left: 462rpx; }
  666. }
  667. @keyframes around10 {
  668. 0% { top: 537rpx; left: 340rpx; }
  669. 15% { top: 400rpx; left: 150rpx; }
  670. 30% { top: 350rpx; left: 450rpx; }
  671. 50% { top: 50rpx; left: 50rpx; }
  672. 70% { top: 450rpx; left: 400rpx; }
  673. 85% { top: 550rpx; left: 120rpx; }
  674. 100% { top: 537rpx; left: 340rpx; }
  675. }
  676. @keyframes around11 {
  677. 0% { top: 480rpx; left: 333rpx; }
  678. 16% { top: 350rpx; left: 464rpx; }
  679. 33% { top: 400rpx; left: 200rpx; }
  680. 50% { top: 200rpx; left: 400rpx; }
  681. 66% { top: 300rpx; left: 100rpx; }
  682. 83% { top: 100rpx; left: 300rpx; }
  683. 100% { top: 480rpx; left: 333rpx; }
  684. }
  685. .ball_0{
  686. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball0.png');
  687. background-repeat: no-repeat;
  688. background-size: 100%;
  689. }
  690. .ball_1{
  691. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball1.png');
  692. background-repeat: no-repeat;
  693. background-size: 100%;
  694. }
  695. .ball_2{
  696. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball2.png');
  697. background-repeat: no-repeat;
  698. background-size: 100%;
  699. }
  700. .ball_3{
  701. background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball3.png');
  702. background-repeat: no-repeat;
  703. background-size: 100%;
  704. }
  705. }
  706. </style>

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/代码探险家/article/detail/831286
推荐阅读
相关标签
  

闽ICP备14008679号