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

这篇具有很好参考价值的文章主要介绍了前端小程序,手把手教你从零开始做一个酷炫的扭蛋机十连抽动画效果。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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

语言环境

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

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

先看效果

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

1.背景

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

<!-- 扭蛋机积分抽奖 -->
<template>
  <view class="page">
    <!-- 扭蛋机背景图片 -->
    <image
      class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
      src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
    />
  </view>
</template>

<script setup lang="ts">

</script>

<style lang="scss" scoped>
.page {
  width: 100vw;
  min-height: 100vh;
  overflow: hidden;
  background-color: #ff0027;
}
</style>

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

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

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

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

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

1.弹幕template:

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

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

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

2.弹幕scss

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

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

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

3.弹幕ts

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

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

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

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

<!-- 扭蛋机积分抽奖 -->
<template>
  <view class="page">
    <!-- 扭蛋机背景图片 -->
    <image
      class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
      src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
    />

    <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
    <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
      <!-- 弹幕 -->
      <image
        v-for="(barrage, i) in barrages"
        :key="i"
        mode="heightFix"
        class="absolute left-[760rpx] h-[88rpx] z-[300]"
        :class="{ 'barrage-animation': barrage.startAnimation }"
        :style="{ top: barrage.top + 'px' }"
        :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
      />
    </view>
  </view>
</template>

<script setup lang="ts">

onLoad(async () => {
  startBarrageAnime() // 初始化静态弹幕效果
})

// 弹幕设置
// 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
const barrages = ref([
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
])
// 开始弹幕动画的函数
// 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
const startBarrageAnime = () => {
  // 遍历弹幕数组
  barrages.value.forEach((barrage, index) => {
    // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
    const randomTop = Math.floor(Math.random() * 200)
    // 计算随机延迟时间,使得弹幕不是同时出现
    const randomDelay = Math.random() * 10000
    // 设置延时函数,到达随机延迟时间后开始弹幕动画
    setTimeout(() => {
      barrage.top = randomTop // 设置弹幕的顶部位置
      barrage.startAnimation = true // 开始动画
    }, randomDelay)
  })
}

</script>

<style lang="scss" scoped>
.page {
  width: 100vw;
  min-height: 100vh;
  overflow: hidden;
  background-color: #ff0027;
}

/* 弹幕平移动画 */
.barrage-animation {
  animation-name: moveLeft; /* 指定动画名称 */
  animation-duration: 10s; /* 指定动画时长 */
  animation-play-state: running; /* 控制动画播放状态 */
  animation-timing-function: linear; /* 指定动画速度曲线 */
  animation-iteration-count: infinite; /* 指定动画循环次数 */
}

/* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
@keyframes moveLeft {
  /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  0% { transform: translate3d(100%, 0, 0) }
  100% { transform: translate3d(-500%, 0, 0) }
}
</style>

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

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

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

1.中奖记录template与scss:

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

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

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

2.中奖记录ts:

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

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

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

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

<!-- 扭蛋机积分抽奖 -->
<template>
  <view class="page">
    <!-- 扭蛋机背景图片 -->
    <image
      class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
      src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
    />

    <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
    <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
      <!-- 弹幕 -->
      <image
        v-for="(barrage, i) in barrages"
        :key="i"
        mode="heightFix"
        class="absolute left-[760rpx] h-[88rpx] z-[300]"
        :class="{ 'barrage-animation': barrage.startAnimation }"
        :style="{ top: barrage.top + 'px' }"
        :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
      />
    </view>

    <!-- 中奖记录 -->
    <view
      v-for="(item,i) in recordList"
      :key="i"
      class="left-bar ellipsis"
      :style="{'top': (item.top > 1624 ? 1624 : item.top) + 'rpx', 'opacity': item.show?1:0, 'z-index': item.show ? 1000: -1 }"
    >
      {{ item.phone + '获得' + item.prizeName }}
    </view>
  </view>
</template>

<script setup lang="ts">
onLoad(async () => {
  startBarrageAnime() // 初始化静态弹幕效果
  initActivityrecord() // 初始化中奖记录效果
})

// 弹幕设置
// 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
const barrages = ref([
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
])
// 开始弹幕动画的函数
// 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
const startBarrageAnime = () => {
  // 遍历弹幕数组
  barrages.value.forEach((barrage, index) => {
    // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
    const randomTop = Math.floor(Math.random() * 200)
    // 计算随机延迟时间,使得弹幕不是同时出现
    const randomDelay = Math.random() * 10000
    // 设置延时函数,到达随机延迟时间后开始弹幕动画
    setTimeout(() => {
      barrage.top = randomTop // 设置弹幕的顶部位置
      barrage.startAnimation = true // 开始动画
    }, randomDelay)
  })
}

/** ******** 获取中奖记录及轮播 begin *************/
interface Record {
  phone: string; // 用户的电话号码
  prizeName: string; // 奖品名称
  top: number; // 记录当前的顶部位置(用于动画或滚动)
  defaultTop: number; // 记录的默认顶部位置(初始位置)
  show: boolean; // 是否显示该记录
}
const recordList = ref<Array<Record>>([]) // 扭蛋中奖记录
const showTop = 786 // 最上面那条广告的高度位置
const showCount = 3 // 显示三条左侧广告消息
const intervalId = ref() // 轮询任务的id,便于退出页面时销毁定时任务
const initActivityrecord = async () => {
  // 模拟中奖数据
  const res = Array.from({ length: 15 }, () => ({ phone: '186****1234', prizeName: '锐星优惠券', top: 0, defaultTop: 0, show: false }))
  recordList.value = res.map((item: Record, i:number) => {
    const top = showTop + (60 * i - 1)
    return {
      ...item,
      top,
      defaultTop: top, // 保存初始高度,便于后面对总漂移数做判断
      show: top >= showTop && top <= showTop + (60 * showCount)
    }
  })
  // 如果总获奖记录不超过要显示的数目个,就不要轮播了
  if (recordList.value.length > (showCount + 1)) {
    intervalId.value = setInterval(updateRecordTop, 3000)
  }
  // 追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true
  // 由于下面有个判断 (recordList.value.length - 4), 所以数组长度小于四的时候就不用追加动画效果了
  function updateRecordTop () {
    recordList.value = recordList.value.map((item: Record, index) => {
      // 更新top值,减去60
      let newTop = item.top - 60
      // 这里做一下判断,如果轮播的高度超出了总个数,就还原高度
      if ((item.defaultTop - item.top) / 60 > (recordList.value.length - 4)) {
        newTop = item.defaultTop
      }
      // 判断新的top值是否在指定范围内,并设置show属性
      const newShow = newTop >= showTop && newTop <= showTop + (60 * showCount)
      return {
        ...item,
        top: newTop, // 更新top属性
        show: newShow // 更新show属性
      }
    })
  }
}

// 离开页面时销毁定时任务
onBeforeUnmount(() => {
  clearInterval(intervalId.value)
})
/** ******** 获取中奖记录及轮播 end *************/

</script>

<style lang="scss" scoped>
.page {
  width: 100vw;
  min-height: 100vh;
  overflow: hidden;
  background-color: #ff0027;
}

/* 弹幕平移动画 */
.barrage-animation {
  animation-name: moveLeft; /* 指定动画名称 */
  animation-duration: 10s; /* 指定动画时长 */
  animation-play-state: running; /* 控制动画播放状态 */
  animation-timing-function: linear; /* 指定动画速度曲线 */
  animation-iteration-count: infinite; /* 指定动画循环次数 */
}

/* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
@keyframes moveLeft {
  /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  0% { transform: translate3d(100%, 0, 0) }
  100% { transform: translate3d(-500%, 0, 0) }
}

/* 中奖记录轮播动画 */
.left-bar{
  position: absolute;
  left: 106rpx;
  z-index: 100;
  max-width: 312rpx;
  padding: 8rpx 16rpx;
  color: #fff;
  font-size: 24rpx;
  background: rgb(0 0 0 / 0.3);
  border-radius: 50rpx;
  transition: all 0.4s ease;
}
.ellipsis {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
</style>

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

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

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

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

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

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

1.小球及容器 template

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

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

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

2.小球及容器 scss

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

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

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

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

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

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

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

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

3.小球及容器 ts

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

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

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

<!-- 扭蛋机积分抽奖 -->
<template>
  <view class="page">
    <!-- 扭蛋机背景图片 -->
    <image
      class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
      src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
    />

    <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
    <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
      <!-- 弹幕 -->
      <image
        v-for="(barrage, i) in barrages"
        :key="i"
        mode="heightFix"
        class="absolute left-[760rpx] h-[88rpx] z-[300]"
        :class="{ 'barrage-animation': barrage.startAnimation }"
        :style="{ top: barrage.top + 'px' }"
        :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
      />
    </view>

    <!-- 中奖记录 -->
    <view
      v-for="(item,i) in recordList"
      :key="i"
      class="left-bar ellipsis"
      :style="{'top': (item.top > 1624 ? 1624 : item.top) + 'rpx', 'opacity': item.show?1:0, 'z-index': item.show ? 1000: -1 }"
    >
      {{ item.phone + '获得' + item.prizeName }}
    </view>

    <!-- 扭蛋机 -->
    <view class="lottery-box">
      <!-- 扭蛋机主框框体 -->
      <view class="lottery-box-content">
        <!-- 扭蛋机小球 -->
        <span
          v-for="(item, i) in balls"
          :key="i"
          ref="balls"
          class="qiu"
          :class="['ball_' + i%4, 'qiu_' + i, 'diaol_' + i, animateClass[i] ? 'run_' + i : '']"
        />
      </view>
      <!-- 扭蛋机左侧按钮 -->
      <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[176rpx] top-[1060rpx]" @click="handleGameGo(1)">
        <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">单抽</text>
        <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">10</text>
      </view>
      <!-- 扭蛋机右侧按钮 -->
      <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[418rpx] top-[1060rpx]" @click="handleGameGo(10)">
        <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">十连</text>
        <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">90</text>
      </view>
      <!-- 扭蛋机积分数据 -->
      <view class="absolute left-0 top-[1212rpx] h-[46rpx] w-full text-[24rpx] text-[#A13000] flex-center">
        积分:10000
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
onLoad(async () => {
  startBarrageAnime() // 初始化静态弹幕效果
  initActivityrecord() // 初始化中奖记录效果
  initBall() // 初始化扭蛋机小球
})

// 弹幕设置
// 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
const barrages = ref([
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
])
// 开始弹幕动画的函数
// 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
const startBarrageAnime = () => {
  // 遍历弹幕数组
  barrages.value.forEach((barrage, index) => {
    // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
    const randomTop = Math.floor(Math.random() * 200)
    // 计算随机延迟时间,使得弹幕不是同时出现
    const randomDelay = Math.random() * 10000
    // 设置延时函数,到达随机延迟时间后开始弹幕动画
    setTimeout(() => {
      barrage.top = randomTop // 设置弹幕的顶部位置
      barrage.startAnimation = true // 开始动画
    }, randomDelay)
  })
}

/** ******** 获取中奖记录及轮播 begin *************/
interface Record {
  phone: string; // 用户的电话号码
  prizeName: string; // 奖品名称
  top: number; // 记录当前的顶部位置(用于动画或滚动)
  defaultTop: number; // 记录的默认顶部位置(初始位置)
  show: boolean; // 是否显示该记录
}
const recordList = ref<Array<Record>>([]) // 扭蛋中奖记录
const showTop = 786 // 最上面那条广告的高度位置
const showCount = 3 // 显示三条左侧广告消息
const intervalId = ref() // 轮询任务的id,便于退出页面时销毁定时任务
const initActivityrecord = async () => {
  // 模拟中奖数据
  const res = Array.from({ length: 15 }, () => ({ phone: '186****1234', prizeName: '锐星优惠券', top: 0, defaultTop: 0, show: false }))
  recordList.value = res.map((item: Record, i:number) => {
    const top = showTop + (60 * i - 1)
    return {
      ...item,
      top,
      defaultTop: top, // 保存初始高度,便于后面对总漂移数做判断
      show: top >= showTop && top <= showTop + (60 * showCount)
    }
  })
  // 如果总获奖记录不超过要显示的数目个,就不要轮播了
  if (recordList.value.length > (showCount + 1)) {
    intervalId.value = setInterval(updateRecordTop, 3000)
  }
  // 追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true
  // 由于下面有个判断 (recordList.value.length - 4), 所以数组长度小于四的时候就不用追加动画效果了
  function updateRecordTop () {
    recordList.value = recordList.value.map((item: Record, index) => {
      // 更新top值,减去60
      let newTop = item.top - 60
      // 这里做一下判断,如果轮播的高度超出了总个数,就还原高度
      if ((item.defaultTop - item.top) / 60 > (recordList.value.length - 4)) {
        newTop = item.defaultTop
      }
      // 判断新的top值是否在指定范围内,并设置show属性
      const newShow = newTop >= showTop && newTop <= showTop + (60 * showCount)
      return {
        ...item,
        top: newTop, // 更新top属性
        show: newShow // 更新show属性
      }
    })
  }
}

// 离开页面时销毁定时任务
onBeforeUnmount(() => {
  clearInterval(intervalId.value)
})
/** ******** 获取中奖记录及轮播 end *************/

const balls = ref<any[]>([])
const animateClass = ref(Array(11).fill(false))
const initBall = () => {
  // 将 balls 数组填充为 DOM 元素的引用
  balls.value = Array.from({ length: 11 }, (_, i) => i + 1).map(i => uni.createSelectorQuery().select('.qiu_' + i))
}

// 开始抽奖
let drawLoading = false
const drawType = ref(1) // 抽奖类型,1单抽 10十抽
const handleGameGo = async (type: number) => {
  if (drawLoading) return
  drawType.value = type
  drawLoading = true

  beginDrawAnimation() // 开始小球乱窜动画

  // 这里仅关闭小球乱窜动画,接着以小球掉落动画取代
  setTimeout(endDrawAnimation, 1400)
  setTimeout(() => {
    drawLoading = false
  }, 2200)

  // 绘制动画
  function beginDrawAnimation () {
    animateClass.value = Array(11).fill(true)
  }
  // 结束动画
  function endDrawAnimation () {
    animateClass.value = Array(11).fill(false)
  }
}

</script>

<style lang="scss" scoped>
.page {
  width: 100vw;
  min-height: 100vh;
  overflow: hidden;
  background-color: #ff0027;
}

/* 弹幕平移动画 */
.barrage-animation {
  animation-name: moveLeft; /* 指定动画名称 */
  animation-duration: 10s; /* 指定动画时长 */
  animation-play-state: running; /* 控制动画播放状态 */
  animation-timing-function: linear; /* 指定动画速度曲线 */
  animation-iteration-count: infinite; /* 指定动画循环次数 */
}

/* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
@keyframes moveLeft {
  /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  0% { transform: translate3d(100%, 0, 0) }
  100% { transform: translate3d(-500%, 0, 0) }
}

/* 中奖记录轮播动画 */
.left-bar{
  position: absolute;
  left: 106rpx;
  z-index: 100;
  max-width: 312rpx;
  padding: 8rpx 16rpx;
  color: #fff;
  font-size: 24rpx;
  background: rgb(0 0 0 / 0.3);
  border-radius: 50rpx;
  transition: all 0.4s ease;
}
.ellipsis {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 以下都是扭蛋机相关的动画 */
.lottery-box .lottery-box-content{
  position: absolute;
  top: 390rpx;
  left: 92rpx;
  z-index: 99;
  width: 566rpx;
  height: 680rpx;
  overflow: hidden;

  .qiu {
    position: absolute;
    display: block;
    width: 100rpx;
    height: 100rpx;
  }

  /* 各个球的定位 */
  .qiu_0 { top: 483rpx; left: 430rpx; }
  .qiu_1 { top: 384rpx; left: 32rpx; }
  .qiu_2 { top: 482rpx; left: 23rpx; }
  .qiu_3 { top: 580rpx; left: 10rpx; }
  .qiu_4 { top: 438rpx; left: 115rpx; }
  .qiu_5 { top: 500rpx; left: 186rpx; }
  .qiu_6 { top: 542rpx; left: 100rpx; }
  .qiu_7 { top: 458rpx; left: 278rpx; }
  .qiu_8 { top: 580rpx; left: 248rpx; }
  .qiu_9 { top: 577rpx; left: 462rpx; }
  .qiu_10 { top: 537rpx; left: 340rpx; }
  .qiu_11 { top: 480rpx; left: 333rpx; }

  .diaol_0{animation:dropOut 1s linear 1.4s backwards;}
  .diaol_0::after{animation-delay:1.3s;}
  .diaol_1{animation:dropOut 1s linear 0.9s backwards;}
  .diaol_1::after{animation-delay:0.8s;}
  .diaol_2{animation:dropOut 1s linear 0.6s backwards;}
  .diaol_2::after{animation-delay:0.5s;}
  .diaol_3{animation:dropOut 1s linear  backwards;}
  .diaol_4{animation:dropOut 1s linear 1.1s backwards;}
  .diaol_4::after{animation-delay:1s;}
  .diaol_5{animation:dropOut 1s linear 0.8s backwards;}
  .diaol_5::after{animation-delay:0.7s;}
  .diaol_6{animation:dropOut 1s linear 0.4s backwards;}
  .diaol_6::after{animation-delay:0.3s;}
  .diaol_7{animation:dropOut 1s linear 0.9s backwards;}
  .diaol_7::after{animation-delay:0.8s;}
  .diaol_8{animation:dropOut 1s linear 0.6s backwards;}
  .diaol_8::after{animation-delay:0.5s;}
  .diaol_9{animation:dropOut 1s linear 1.1s backwards;}
  .diaol_9::after{animation-delay:1s;}
  .diaol_10{animation:dropOut 1s linear 0.2s backwards;}
  .diaol_11{animation:dropOut 1s linear 1.4s backwards;}
  .diaol_11::after{animation-delay:1.3s;}

  @keyframes dropOut {
    0% { transform: translateY(-200%); opacity: 0; }
    5% { transform: translateY(-200%); }
    15% { transform: translateY(0); }
    30% { transform: translateY(-100%); }
    40% { transform: translateY(0%); }
    50% { transform: translateY(-60%); }
    70% { transform: translateY(0%); }
    80% { transform: translateY(-30%); }
    90% { transform: translateY(0%); }
    95% { transform: translateY(-14%); }
    97% { transform: translateY(0%); }
    99% { transform: translateY(-6%); }
    100% { transform: translateY(0); opacity: 1; }
  }

  .run_0 {animation:around0  1.5s linear  infinite;}
  .run_1 {animation:around1  1.5s linear  infinite;}
  .run_2 {animation:around2  1.5s linear  infinite;}
  .run_3 {animation:around3  1.5s linear  infinite;}
  .run_4 {animation:around4  1.5s linear  infinite;}
  .run_5 {animation:around5  1.5s linear  infinite;}
  .run_6 {animation:around6  1.5s linear  infinite;}
  .run_7 {animation:around7  1.5s linear  infinite;}
  .run_8 {animation:around8  1.5s linear  infinite;}
  .run_9 {animation:around9  1.5s linear  infinite;}
  .run_10{animation:around10 1.5s linear  infinite;}
  .run_11{animation:around11 1.5s linear  infinite;}

  /* 移动范围 left 0-464 ;  top 0-536 */
  /* 这里解释一下为什么小球变换要用定位而不是更省性能transform: 因为部分机型对transform支持性不好,会被过滤掉(比如ios上transform2d不生效,1加ace2对transform单项变换不生效)因此这种关键动画使用定位来执行 */
  @keyframes around0 {
    0%    { top: 483rpx; left: 430rpx; }
    20%   {   top: 536rpx; left: 0rpx; }
    40%   { top: 0rpx; left: 464rpx;   }
    60%   {   top: 200rpx; left: 0rpx; }
    80%   { top: 3rpx; left: 303rpx;   }
    100%  { top: 483rpx; left: 430rpx; }
  }
  @keyframes around1 {
    0%    {  top: 384rpx; left: 32rpx; }
    16%   { top: 200rpx; left: 450rpx; }
    32%   {   top: 24rpx; left: 8rpx;  }
    48%   { top: 500rpx; left: 106rpx; }
    64%   {   top: 6rpx; left: 0rpx;   }
    82%   { top: 180rpx; left: 464rpx; }
    100%  {  top: 384rpx; left: 32rpx; }
  }

  @keyframes around2 {
    0%    {  top: 482rpx; left: 23rpx; }
    20%   { top: 64rpx; left: 448rpx;   }
    40%   {  top: 520rpx; left: 16rpx;  }
    60%   { top: 208rpx; left: 432rpx;  }
    80%   {  top: 8rpx; left: 80rpx;    }
    100%  {  top: 482rpx; left: 23rpx;  }
  }

  @keyframes around3 {
    0%    {  top: 580rpx; left: 10rpx; }
    20%   { top: 100rpx; left: 300rpx;  }
    40%   {  top: 20rpx; left: 10rpx;   }
    60%   { top: 400rpx; left: 460rpx;  }
    80%   { top: 68rpx; left: 220rpx;   }
    100%  {  top: 580rpx; left: 10rpx;  }
  }

  @keyframes around4 {
    0%    { top: 438rpx; left: 115rpx; }
    16%   { top: 10rpx; left: 300rpx;  }
    32%   {  top: 530rpx; left: 30rpx;  }
    48%   { top: 200rpx; left: 450rpx;  }
    64%   {  top: 300rpx; left: 20rpx;  }
    82%   { top: 560rpx; left: 450rpx;  }
    100%  { top: 438rpx; left: 115rpx;  }
  }

  @keyframes around5 {
    0%    { top: 500rpx; left: 186rpx; }
    20%   {  top: 200rpx; left: 50rpx;  }
    40%   { top: 350rpx; left: 400rpx;  }
    60%   { top: 530rpx; left: 100rpx;  }
    80%   { top: 100rpx; left: 380rpx;  }
    100%  { top: 500rpx; left: 186rpx;  }
  }

  @keyframes around6 {
    0%    {  top: 542rpx; left: 100rpx; }
    15%   {  top: 300rpx; left: 300rpx;  }
    30%   {  top: 100rpx; left: 100rpx;  }
    45%   {  top: 200rpx; left: 400rpx;  }
    60%   {  top: 400rpx; left: 200rpx;  }
    75%   {  top: 100rpx; left: 450rpx;  }
    100%  {  top: 542rpx; left: 100rpx;  }
  }

  @keyframes around7 {
    0%    {  top: 458rpx; left: 278rpx; }
    15%   {   top: 200rpx; left: 50rpx;  }
    35%   {  top: 150rpx; left: 450rpx;  }
    55%   {   top: 520rpx; left: 50rpx;  }
    75%   {  top: 250rpx; left: 450rpx;  }
    90%   {   top: 530rpx; left: 20rpx;  }
    100%  {  top: 458rpx; left: 278rpx;  }
  }

  @keyframes around8 {
    0%    {  top: 580rpx; left: 248rpx;  }
    20%   {   top: 350rpx; left: 50rpx;  }
    40%   {  top: 10rpx; left: 460rpx;   }
    60%   {  top: 536rpx; left: 460rpx;  }
    80%   {  top: 20rpx; left: 380rpx;   }
    100%  {  top: 580rpx; left: 248rpx;  }
  }

  @keyframes around9 {
    0%    {  top: 577rpx; left: 462rpx; }
    12.5% {  top: 400rpx; left: 300rpx;  }
    25%   {  top: 450rpx; left: 500rpx;  }
    37.5% {  top: 350rpx; left: 200rpx;  }
    50%   {  top: 250rpx; left: 450rpx;  }
    62.5% {  top: 400rpx; left: 150rpx;  }
    75%   {  top: 150rpx; left: 350rpx;  }
    87.5% {  top: 500rpx; left: 250rpx;  }
    100%  {  top: 577rpx; left: 462rpx;  }
  }

  @keyframes around10 {
    0%    {  top: 537rpx; left: 340rpx;  }
    15%   {  top: 400rpx; left: 150rpx;  }
    30%   {  top: 350rpx; left: 450rpx;  }
    50%   {   top: 50rpx; left: 50rpx;   }
    70%   {  top: 450rpx; left: 400rpx;  }
    85%   {  top: 550rpx; left: 120rpx;  }
    100%  {  top: 537rpx; left: 340rpx;  }
  }

  @keyframes around11 {
    0%   { top: 480rpx; left: 333rpx; }
    16%  { top: 350rpx; left: 464rpx; }
    33%  { top: 400rpx; left: 200rpx; }
    50%  { top: 200rpx; left: 400rpx; }
    66%  { top: 300rpx; left: 100rpx; }
    83%  { top: 100rpx; left: 300rpx; }
    100% { top: 480rpx; left: 333rpx; }
  }

  .ball_0{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball0.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .ball_1{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball1.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .ball_2{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball2.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .ball_3{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball3.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }
}

</style>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

十连抽scss:

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

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

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

前端小程序,手把手教你从零开始做一个酷炫的扭蛋机十连抽动画效果,前端,uni-app,小程序,动画效果如下:

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

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

7.案例源码

注释写的很详细,可能有些同学没有在 tailwindcss 环境下,不过问题不大,那些模板中的样式都能望文知义,很好理解。文章来源地址https://www.toymoban.com/news/detail-849905.html

<!-- 扭蛋机积分抽奖 -->
<template>
  <view class="page">
    <!-- 扭蛋机背景图片 -->
    <image
      class="absolute top-[24rpx] left-0 h-[1444rpx] w-full"
      src="https://gitee.com/jingkunxu/img/raw/master/note/blog/lottery-bg0.png"
    />

    <!-- 这里给弹幕增加一个外盒子,确保弹幕超出外框隐藏,以应对ios中页面元素超出时页面可以左右滑动的问题 -->
    <view class="w-[750rpx] h-[600rpx] absolute left-0 top-[280rpx] overflow-hidden">
      <!-- 弹幕 -->
      <image
        v-for="(barrage, i) in barrages"
        :key="i"
        mode="heightFix"
        class="absolute left-[760rpx] h-[88rpx] z-[300]"
        :class="{ 'barrage-animation': barrage.startAnimation }"
        :style="{ top: barrage.top + 'px' }"
        :src="'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage' + (i+1) + '.png'"
      />
    </view>

    <!-- 中奖记录 -->
    <view
      v-for="(item,i) in recordList"
      :key="i"
      class="left-bar ellipsis"
      :style="{'top': (item.top > 1624 ? 1624 : item.top) + 'rpx', 'opacity': item.show?1:0, 'z-index': item.show ? 1000: -1 }"
    >
      {{ item.phone + '获得' + item.prizeName }}
    </view>

    <!-- 扭蛋机 -->
    <view class="lottery-box">
      <!-- 扭蛋机主框框体 -->
      <view class="lottery-box-content">
        <!-- 扭蛋机小球 -->
        <span
          v-for="(item, i) in balls"
          :key="i"
          ref="balls"
          class="qiu"
          :class="['ball_' + i%4, 'qiu_' + i, 'diaol_' + i, animateClass[i] ? 'run_' + i : '']"
        />
      </view>
      <!-- 扭蛋机左侧按钮 -->
      <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[176rpx] top-[1060rpx]" @click="handleGameGo(1)">
        <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">单抽</text>
        <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">10</text>
      </view>
      <!-- 扭蛋机右侧按钮 -->
      <view class="absolute rounded-full w-[160rpx] h-[160rpx] left-[418rpx] top-[1060rpx]" @click="handleGameGo(10)">
        <text class="block text-[56rpx] text-[#fff] font-bold mt-[16rpx] flex-center">十连</text>
        <text class="block text-[22rpx] text-[#fff] mt-[10rpx] flex-center">90</text>
      </view>
      <!-- 扭蛋机积分数据 -->
      <view class="absolute left-0 top-[1212rpx] h-[46rpx] w-full text-[24rpx] text-[#A13000] flex-center">
        积分:10000
      </view>
    </view>

    <view class="h-[1342rpx]" /><!-- 占位至扭蛋机末端,恢复文档流 -->

    <!-- 奖品展示标题,这里有个比较弱的动画效果,两边的箭头会往中间的文字部分指向 -->
    <view class="my-[24rpx] flex-center">
      <!-- 左外侧箭头,会先亮起 -->
      <view class="triangle outside" style="transform: rotate(90deg);" />
      <!-- 左内侧箭头,稍微后亮 -->
      <view class="triangle inside" style="transform: rotate(90deg);" />
      <view class="mx-[16rpx] text-[36rpx] text-[#ffffff] font-medium z-10">
        奖品展示
      </view>
      <!-- 右内侧箭头,后亮 -->
      <view class="triangle inside" style="transform: rotate(-90deg);" />
      <!-- 右外侧箭头,先亮起来 -->
      <view class="triangle outside" style="transform: rotate(-90deg);" />
    </view>

    <!-- 奖品展示区 -->
    <view class="relative w-[702rpx] mx-[24rpx] py-[36rpx] pl-[42rpx] flex flex-wrap bg-[#fff] z-10 rounded-[16rpx]">
      <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]">
        <!-- 奖品封面 -->
        <image class="rounded-[16rpx] bg-[#FF0000] w-[190rpx] h-[162rpx]" mode="aspectFill" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/goods.jpg" />
        <!-- 奖品名称 -->
        <view class="my-[8rpx] ellipsis w-[190rpx] break-all text-[#333] text-[24rpx]">
          锐星优惠券
        </view>
        <view class="text-[24rpx] text-[#FF0000] line-through">
          ¥5.5
        </view>
      </view>
    </view>

    <!-- 占位盒,避免最底部贴紧边缘,一般都是用padding-bottom占位,这里单独用一个盒子是为了显眼,因为本页绝对定位的元素较多 -->
    <view class="w-full h-[64rpx]" />

    <!-- 遮罩与弹窗 -->
    <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]">
      <!-- 关闭按钮 -->
      <!-- 给关闭按钮加上一个更广的事件触发范围,不然用户不容易点到 -->
      <view class="absolute w-[80rpx] h-[80rpx] top-[330rpx] right-[62rpx] z-[10020] flex-center" @click="closeDialog">
        <image class="w-[42rpx] h-[42rpx]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/closed.png" />
      </view>

      <!-- 单抽显示奖品 -->
      <template v-if="drawType===1">
        <!-- 商品名称 -->
        <view class="absolute flicker w-full h-[40rpx] px-[32rpx] flex-center top-[500rpx] text-[26rpx] text-[#fff] font-semibold">
          —— 锐星优惠券 ——
        </view>
        <!-- 底部背景 -->
        <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" />

        <!-- 奖品图片 -->
        <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" />

        <!-- 上层遮罩 -->
        <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" />

        <!-- 白光扫描层,增加一道扫描动画使商品效果更为立体 -->
        <view v-if="dialogObj.show" class="rounded-full overflow-hidden absolute  w-[430rpx] h-[430rpx] left-[160rpx] top-[568rpx] z-[10020]">
          <!-- 扫描主体 -->
          <view class="scan-overlay" />
        </view>
      </template>

      <!-- 十连展示商品 -->
      <template v-if="drawType===10">
        <!-- 顶部恭喜获得 -->
        <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" />
        <!-- 每一个中奖盒子 -->
        <view
          v-for="(item,i) in tenMap"
          :key="i"
          :style="{'left': item.left, 'top': item.top, 'animation-delay': (i*0.05+0.2) + 's'}"
          class="absolute w-[172rpx] h-[172rpx] loaded"
        >
          <!-- 扭蛋内容,正面底图,背面商品 -->
          <view class="toy-box toy" :style="{'animation-delay': (i*0.05+0.6) + 's'}">
            <!-- 商品图 -->
            <div class="front" :style="{'animation-delay': (i*0.05+0.6) + 's'}">
              <image src="https://gitee.com/jingkunxu/img/raw/master/note/blog/goods.jpg" alt="Back" />
            </div>
            <!-- 背景图 -->
            <div class="back" :style="{'animation-delay': (i*0.05+0.6) + 's'}">
              <image src="https://gitee.com/jingkunxu/img/raw/master/note/blog/result5.png" alt="Front" />
            </div>
          </view>

          <!-- 白光扫描层,增加一道扫描动画使商品效果更为立体 -->
          <view class="toy-box absolute z-[10020]">
            <!-- 扫描主体 -->
            <view class="scan-overlay-ten" :style="{'animation-delay': (item.delay*0.1 + 1.6) + 's'}" />
          </view>
          <!-- 商品遮罩 -->
          <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" />
          <view class="mt-[12rpx] w-full h-[60rpx] text-[22rpx] text-[#fff] text-line-2 font-semibold text-center">
            锐星优惠券
          </view>
        </view>
      </template>

      <!-- 左侧按钮 -->
      <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">
        <!-- 左侧按钮背景图 -->
        <image class="absolute w-full h-full insert-0 z-[10020]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/left.png" />
        <!-- 左侧按钮名 -->
        <text class="text-[40rpx] text-[#fff] font-bold z-[10030]">分享</text>
      </button>
      <!-- 右侧按钮 -->
      <view class="absolute w-[226rpx] h-[84.0rpx] left-[408rpx] z-[10020] flex-center" :style="{top: drawType===10? '1170rpx': '958rpx'}" @click="onceAgain">
        <!-- 右侧按钮背景图 -->
        <image class="absolute w-full h-full insert-0 z-[10020]" src="https://gitee.com/jingkunxu/img/raw/master/note/blog/right.png" />
        <!-- 右侧按钮名 -->
        <text class="text-[40rpx] text-[#fff] font-bold z-[10030]">{{ drawType===1 ? '再抽一次':'再抽十次' }}</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
onLoad(async () => {
  startBarrageAnime() // 初始化静态弹幕效果
  initActivityrecord() // 初始化中奖记录效果
  initBall() // 初始化扭蛋机小球
})

// 弹幕设置
// 每个弹幕对象包含:top(弹幕的顶部位置)、startAnimation(控制动画是否开始)、src(弹幕内容或来源)
const barrages = ref([
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage1.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage2.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage3.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage4.png' },
  { top: 0, startAnimation: false, src: 'https://gitee.com/jingkunxu/img/raw/master/note/blog/barrage5.png' }
])
// 开始弹幕动画的函数
// 这里弹幕我没有设置随机重新刷新,因为CSS动画已经是循环播放的,如果想要更加随机一点,后面可以再优化
const startBarrageAnime = () => {
  // 遍历弹幕数组
  barrages.value.forEach((barrage, index) => {
    // 计算随机顶部位置,使得弹幕垂直方向分布在一定范围内
    const randomTop = Math.floor(Math.random() * 200)
    // 计算随机延迟时间,使得弹幕不是同时出现
    const randomDelay = Math.random() * 10000
    // 设置延时函数,到达随机延迟时间后开始弹幕动画
    setTimeout(() => {
      barrage.top = randomTop // 设置弹幕的顶部位置
      barrage.startAnimation = true // 开始动画
    }, randomDelay)
  })
}

/** ******** 获取中奖记录及轮播 begin *************/
interface Record {
  phone: string; // 用户的电话号码
  prizeName: string; // 奖品名称
  top: number; // 记录当前的顶部位置(用于动画或滚动)
  defaultTop: number; // 记录的默认顶部位置(初始位置)
  show: boolean; // 是否显示该记录
}
const recordList = ref<Array<Record>>([]) // 扭蛋中奖记录
const showTop = 786 // 最上面那条广告的高度位置
const showCount = 3 // 显示三条左侧广告消息
const intervalId = ref() // 轮询任务的id,便于退出页面时销毁定时任务
const initActivityrecord = async () => {
  // 模拟中奖数据
  const res = Array.from({ length: 15 }, () => ({ phone: '186****1234', prizeName: '锐星优惠券', top: 0, defaultTop: 0, show: false }))
  recordList.value = res.map((item: Record, i:number) => {
    const top = showTop + (60 * i - 1)
    return {
      ...item,
      top,
      defaultTop: top, // 保存初始高度,便于后面对总漂移数做判断
      show: top >= showTop && top <= showTop + (60 * showCount)
    }
  })
  // 如果总获奖记录不超过要显示的数目个,就不要轮播了
  if (recordList.value.length > (showCount + 1)) {
    intervalId.value = setInterval(updateRecordTop, 3000)
  }
  // 追加一个定时任务,每隔三秒将 recordList 里面每一项的 top 值减去60; 并且判断 在 top >= showTop && top <= showTop + (60 * showCount) 的范围内 设 show 为 true
  // 由于下面有个判断 (recordList.value.length - 4), 所以数组长度小于四的时候就不用追加动画效果了
  function updateRecordTop () {
    recordList.value = recordList.value.map((item: Record, index) => {
      // 更新top值,减去60
      let newTop = item.top - 60
      // 这里做一下判断,如果轮播的高度超出了总个数,就还原高度
      if ((item.defaultTop - item.top) / 60 > (recordList.value.length - 4)) {
        newTop = item.defaultTop
      }
      // 判断新的top值是否在指定范围内,并设置show属性
      const newShow = newTop >= showTop && newTop <= showTop + (60 * showCount)
      return {
        ...item,
        top: newTop, // 更新top属性
        show: newShow // 更新show属性
      }
    })
  }
}

// 离开页面时销毁定时任务
onBeforeUnmount(() => {
  clearInterval(intervalId.value)
})
/** ******** 获取中奖记录及轮播 end *************/

const balls = ref<any[]>([])
const animateClass = ref(Array(11).fill(false))
const initBall = () => {
  // 将 balls 数组填充为 DOM 元素的引用
  balls.value = Array.from({ length: 11 }, (_, i) => i + 1).map(i => uni.createSelectorQuery().select('.qiu_' + i))
}

// 开始抽奖
let drawLoading = false
const drawType = ref(1) // 抽奖类型,1单抽 10十抽
const handleGameGo = async (type: number) => {
  if (drawLoading) return
  drawType.value = type
  drawLoading = true

  beginDrawAnimation() // 开始小球乱窜动画

  // 这里仅关闭小球乱窜动画,接着以小球掉落动画取代
  setTimeout(endDrawAnimation, 1400)
  setTimeout(() => {
    drawLoading = false
    dialogObj.show = true // 显示遮罩层
  }, 2200)

  // 绘制动画
  function beginDrawAnimation () {
    animateClass.value = Array(11).fill(true)
  }
  // 结束动画
  function endDrawAnimation () {
    animateClass.value = Array(11).fill(false)
  }
}

// 弹窗对象
const dialogObj = reactive({
  show: false, // 是否显示弹窗
  awardList: [{ prizeName: '', images: [{ narrowUrl: '' }] }] // 弹窗内容
})
const closeDialog = () => {
  dialogObj.show = false
}

// 十连弹窗的位置设置
const tenMap = [
  { left: '106rpx', top: '500rpx', delay: 2 },
  { left: '289rpx', top: '500rpx', delay: 3 },
  { left: '470rpx', top: '500rpx', delay: 4 },

  { left: '25rpx', top: '710rpx', delay: 1 },
  { left: '208rpx', top: '710rpx', delay: 2 },
  { left: '391rpx', top: '710rpx', delay: 3 },
  { left: '574rpx', top: '710rpx', delay: 4 },

  { left: '106rpx', top: '920rpx', delay: 1 },
  { left: '289rpx', top: '920rpx', delay: 2 },
  { left: '470rpx', top: '920rpx', delay: 3 }
]

// 抽奖结束,再抽一次
const onceAgain = () => {
  closeDialog()
  handleGameGo(drawType.value)
}

</script>

<style lang="scss" scoped>
.page {
  width: 100vw;
  min-height: 100vh;
  overflow: hidden;
  background-color: #ff0027;
}

/* 弹幕平移动画 */
.barrage-animation {
  animation-name: moveLeft; /* 指定动画名称 */
  animation-duration: 10s; /* 指定动画时长 */
  animation-play-state: running; /* 控制动画播放状态 */
  animation-timing-function: linear; /* 指定动画速度曲线 */
  animation-iteration-count: infinite; /* 指定动画循环次数 */
}

/* 这里解释一下为什么用 translate3d 而不是 translateX,主要原因是为了利用硬件加速,提高动画的性能和流畅性。尤其是iOS上浏览器在处理3D变换时通常会触发硬件加速,并且最重要的是非 translate3d 效果在ios的小程序上经常被吞(可恶的ios) */
@keyframes moveLeft {
  /* 这里用百分比平移变换的原因是每个弹幕的长度不一样,导致的平移的速度也会有差别,能起到更加随机的视觉效果 */
  0% { transform: translate3d(100%, 0, 0) }
  100% { transform: translate3d(-500%, 0, 0) }
}

/* 中奖记录轮播动画 */
.left-bar{
  position: absolute;
  left: 106rpx;
  z-index: 100;
  max-width: 312rpx;
  padding: 8rpx 16rpx;
  color: #fff;
  font-size: 24rpx;
  background: rgb(0 0 0 / 0.3);
  border-radius: 50rpx;
  transition: all 0.4s ease;
}
.ellipsis {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 缓入动画,百搭 */
.loaded{
  opacity: 0;
  animation: show .5s ease forwards;
  @keyframes show{
    from{ opacity: 0; }
    to{ opacity: 1; }
  }
}

/**
 * 三角标题动画
 * 动画核心是让外侧的三角形先开始亮,极短的时间内内侧三角形再亮,造成一种箭头指向的视觉效果
 */
 .triangle {
  position: relative;
  width: 0;
  height: 0;
  border-color: transparent transparent #fff;
  border-style: solid;
  border-width: 0 16rpx 28rpx;

  &.inside  {
    animation: Inside 2s infinite;
    @keyframes Inside {
      0% { opacity: 0.7; }
      27% { opacity: 0.7; }
      57% { opacity: 1; }
      80% { opacity: 1; }
      100% { opacity: 0.7; }
    }
  }

  &.outside {
    animation: Outside 2s infinite;
    @keyframes Outside {
      0% { opacity: 0.7; }
      20% { opacity: 0.7; }
      50% { opacity: 1; }
      80% { opacity: 1; }
      100% { opacity: 0.7; }
    }
  }
}

/**
 * 商品扫描动画
 * 通过渐变让一道白光一闪而过
 * 一开始使用的斜向位移,后来发觉只要背景是斜的,哪怕只做一个维度的平移而形成的视觉效果也像斜向扫描,因此去掉水平渐变
 */
 .scan-overlay {
  position: absolute;
  top: -400rpx;
  left: -100rpx;
  width: 550rpx;
  height: 550rpx;
  background: linear-gradient(
    to right top,
    transparent 0%,
    transparent 40%, /* 开始渐变前的透明区域 */
    rgb(255 255 255 / 0.1) 50%, /* 渐变开始,较低的透明度 */
    rgb(255 255 255 / 0.6) 60%, /* 渐变中间,较高的透明度 */
    rgb(255 255 255 / 0.1) 70%, /* 渐变结束,较低的透明度 */
    transparent 80%, /* 结束渐变后的透明区域 */
    transparent 100%
  );

  /* 设置足够长的时间让效果看起来是轮次执行的感觉 */
  animation: scan 6s 0.6s linear infinite;

  /* 下面这个关键帧的时间不要改广了,不然像是乱窜 */
  @keyframes scan{
    0%{ top: 600rpx; }
    10%{ top: -400rpx; }
    100%{ top: -400rpx; }
  }
}

// 给商品名称一个扫描同步的闪动动画
.flicker{
  opacity: 0.8;

  // 注意如果要改这里的时间,上面扫描的时间最好也要同步更改
  animation: flicker 6s 1s linear infinite;
  @keyframes flicker{
    0%{ opacity: 0.8; }
    14%{ opacity: 0.8; }
    20%{ opacity: 1; }
    36%{ opacity: 1; }
    42%{ opacity: 0.8; }
    100%{ opacity: 0.8 }
  }
}

// 十连扫光动画
.scan-overlay-ten {
  position: absolute;
  top: 200rpx;
  left: -70rpx;
  width: 300rpx;
  height: 330rpx;
  background: linear-gradient(
    to right top,
    transparent 0%,
    transparent 40%, /* 开始渐变前的透明区域 */
    rgb(255 255 255 / 0.1) 50%, /* 渐变开始,较低的透明度 */
    rgb(255 255 255 / 0.6) 60%, /* 渐变中间,较高的透明度 */
    rgb(255 255 255 / 0.1) 70%, /* 渐变结束,较低的透明度 */
    transparent 80%, /* 结束渐变后的透明区域 */
    transparent 100%
  );

  /* 设置足够长的时间让效果看起来是轮次执行的感觉 */
  animation: scan-ten 6s linear infinite;

  /* 下面这个关键帧的时间不要改广了,不然像是乱窜 */
  @keyframes scan-ten{
    0%{ top: 200rpx; }
    15%{ top: -300rpx; }
    100%{ top: -300rpx; }
  }
}

/* 十连扭蛋的奖品 */
.toy-box{
  left: 22rpx;
  top: 4rpx;
  width: 136rpx;
  height: 136rpx;
  border-radius: 200rpx;
  overflow: hidden;
}
.toy{
  position: relative;
  perspective: 1000px;
  transform: rotateY(180deg);
  animation: flipAnimation 1s 1s forwards; /* 动画名称,持续时间,延迟时间,填充模式 */

  .front, .back {
    position: absolute;
    width: 100%;
    height: 100%;
  }

  .front image, .back image {
    width: 100%;
    height: 100%;
  }

  /* 小程序中大部分机型不支持 backface-visibility: hidden,所以这使用opacity来控制显隐 */
  .front {
    opacity: 0;
    animation: o-1 1s 1s forwards;
    @keyframes o-1 {
      from {opacity: 0;}
      to { opacity: 1; }
    }
  }
  .back {
    animation: o-0 1s 1s forwards;

    /* transform: rotateY(360deg); */
    @keyframes o-0 {
      from {opacity: 1;}
      to { opacity: 0; }
    }
  }

  @keyframes flipAnimation {
    from {transform: rotateY(180deg);}
    to {transform: rotateY(360deg);}
  }
}

/* 以下都是扭蛋机相关的动画 */
.lottery-box .lottery-box-content{
  position: absolute;
  top: 390rpx;
  left: 92rpx;
  z-index: 99;
  width: 566rpx;
  height: 680rpx;
  overflow: hidden;

  .qiu {
    position: absolute;
    display: block;
    width: 100rpx;
    height: 100rpx;
  }

  /* 各个球的定位 */
  .qiu_0 { top: 483rpx; left: 430rpx; }
  .qiu_1 { top: 384rpx; left: 32rpx; }
  .qiu_2 { top: 482rpx; left: 23rpx; }
  .qiu_3 { top: 580rpx; left: 10rpx; }
  .qiu_4 { top: 438rpx; left: 115rpx; }
  .qiu_5 { top: 500rpx; left: 186rpx; }
  .qiu_6 { top: 542rpx; left: 100rpx; }
  .qiu_7 { top: 458rpx; left: 278rpx; }
  .qiu_8 { top: 580rpx; left: 248rpx; }
  .qiu_9 { top: 577rpx; left: 462rpx; }
  .qiu_10 { top: 537rpx; left: 340rpx; }
  .qiu_11 { top: 480rpx; left: 333rpx; }

  .diaol_0{animation:dropOut 1s linear 1.4s backwards;}
  .diaol_0::after{animation-delay:1.3s;}
  .diaol_1{animation:dropOut 1s linear 0.9s backwards;}
  .diaol_1::after{animation-delay:0.8s;}
  .diaol_2{animation:dropOut 1s linear 0.6s backwards;}
  .diaol_2::after{animation-delay:0.5s;}
  .diaol_3{animation:dropOut 1s linear  backwards;}
  .diaol_4{animation:dropOut 1s linear 1.1s backwards;}
  .diaol_4::after{animation-delay:1s;}
  .diaol_5{animation:dropOut 1s linear 0.8s backwards;}
  .diaol_5::after{animation-delay:0.7s;}
  .diaol_6{animation:dropOut 1s linear 0.4s backwards;}
  .diaol_6::after{animation-delay:0.3s;}
  .diaol_7{animation:dropOut 1s linear 0.9s backwards;}
  .diaol_7::after{animation-delay:0.8s;}
  .diaol_8{animation:dropOut 1s linear 0.6s backwards;}
  .diaol_8::after{animation-delay:0.5s;}
  .diaol_9{animation:dropOut 1s linear 1.1s backwards;}
  .diaol_9::after{animation-delay:1s;}
  .diaol_10{animation:dropOut 1s linear 0.2s backwards;}
  .diaol_11{animation:dropOut 1s linear 1.4s backwards;}
  .diaol_11::after{animation-delay:1.3s;}

  @keyframes dropOut {
    0% { transform: translateY(-200%); opacity: 0; }
    5% { transform: translateY(-200%); }
    15% { transform: translateY(0); }
    30% { transform: translateY(-100%); }
    40% { transform: translateY(0%); }
    50% { transform: translateY(-60%); }
    70% { transform: translateY(0%); }
    80% { transform: translateY(-30%); }
    90% { transform: translateY(0%); }
    95% { transform: translateY(-14%); }
    97% { transform: translateY(0%); }
    99% { transform: translateY(-6%); }
    100% { transform: translateY(0); opacity: 1; }
  }

  .run_0 {animation:around0  1.5s linear  infinite;}
  .run_1 {animation:around1  1.5s linear  infinite;}
  .run_2 {animation:around2  1.5s linear  infinite;}
  .run_3 {animation:around3  1.5s linear  infinite;}
  .run_4 {animation:around4  1.5s linear  infinite;}
  .run_5 {animation:around5  1.5s linear  infinite;}
  .run_6 {animation:around6  1.5s linear  infinite;}
  .run_7 {animation:around7  1.5s linear  infinite;}
  .run_8 {animation:around8  1.5s linear  infinite;}
  .run_9 {animation:around9  1.5s linear  infinite;}
  .run_10{animation:around10 1.5s linear  infinite;}
  .run_11{animation:around11 1.5s linear  infinite;}

  /* 移动范围 left 0-464 ;  top 0-536 */
  /* 这里解释一下为什么小球变换要用定位而不是更省性能transform: 因为部分机型对transform支持性不好,会被过滤掉(比如ios上transform2d不生效,1加ace2对transform单项变换不生效)因此这种关键动画使用定位来执行 */
  @keyframes around0 {
    0%    { top: 483rpx; left: 430rpx; }
    20%   {   top: 536rpx; left: 0rpx; }
    40%   { top: 0rpx; left: 464rpx;   }
    60%   {   top: 200rpx; left: 0rpx; }
    80%   { top: 3rpx; left: 303rpx;   }
    100%  { top: 483rpx; left: 430rpx; }
  }
  @keyframes around1 {
    0%    {  top: 384rpx; left: 32rpx; }
    16%   { top: 200rpx; left: 450rpx; }
    32%   {   top: 24rpx; left: 8rpx;  }
    48%   { top: 500rpx; left: 106rpx; }
    64%   {   top: 6rpx; left: 0rpx;   }
    82%   { top: 180rpx; left: 464rpx; }
    100%  {  top: 384rpx; left: 32rpx; }
  }

  @keyframes around2 {
    0%    {  top: 482rpx; left: 23rpx; }
    20%   { top: 64rpx; left: 448rpx;   }
    40%   {  top: 520rpx; left: 16rpx;  }
    60%   { top: 208rpx; left: 432rpx;  }
    80%   {  top: 8rpx; left: 80rpx;    }
    100%  {  top: 482rpx; left: 23rpx;  }
  }

  @keyframes around3 {
    0%    {  top: 580rpx; left: 10rpx; }
    20%   { top: 100rpx; left: 300rpx;  }
    40%   {  top: 20rpx; left: 10rpx;   }
    60%   { top: 400rpx; left: 460rpx;  }
    80%   { top: 68rpx; left: 220rpx;   }
    100%  {  top: 580rpx; left: 10rpx;  }
  }

  @keyframes around4 {
    0%    { top: 438rpx; left: 115rpx; }
    16%   { top: 10rpx; left: 300rpx;  }
    32%   {  top: 530rpx; left: 30rpx;  }
    48%   { top: 200rpx; left: 450rpx;  }
    64%   {  top: 300rpx; left: 20rpx;  }
    82%   { top: 560rpx; left: 450rpx;  }
    100%  { top: 438rpx; left: 115rpx;  }
  }

  @keyframes around5 {
    0%    { top: 500rpx; left: 186rpx; }
    20%   {  top: 200rpx; left: 50rpx;  }
    40%   { top: 350rpx; left: 400rpx;  }
    60%   { top: 530rpx; left: 100rpx;  }
    80%   { top: 100rpx; left: 380rpx;  }
    100%  { top: 500rpx; left: 186rpx;  }
  }

  @keyframes around6 {
    0%    {  top: 542rpx; left: 100rpx; }
    15%   {  top: 300rpx; left: 300rpx;  }
    30%   {  top: 100rpx; left: 100rpx;  }
    45%   {  top: 200rpx; left: 400rpx;  }
    60%   {  top: 400rpx; left: 200rpx;  }
    75%   {  top: 100rpx; left: 450rpx;  }
    100%  {  top: 542rpx; left: 100rpx;  }
  }

  @keyframes around7 {
    0%    {  top: 458rpx; left: 278rpx; }
    15%   {   top: 200rpx; left: 50rpx;  }
    35%   {  top: 150rpx; left: 450rpx;  }
    55%   {   top: 520rpx; left: 50rpx;  }
    75%   {  top: 250rpx; left: 450rpx;  }
    90%   {   top: 530rpx; left: 20rpx;  }
    100%  {  top: 458rpx; left: 278rpx;  }
  }

  @keyframes around8 {
    0%    {  top: 580rpx; left: 248rpx;  }
    20%   {   top: 350rpx; left: 50rpx;  }
    40%   {  top: 10rpx; left: 460rpx;   }
    60%   {  top: 536rpx; left: 460rpx;  }
    80%   {  top: 20rpx; left: 380rpx;   }
    100%  {  top: 580rpx; left: 248rpx;  }
  }

  @keyframes around9 {
    0%    {  top: 577rpx; left: 462rpx; }
    12.5% {  top: 400rpx; left: 300rpx;  }
    25%   {  top: 450rpx; left: 500rpx;  }
    37.5% {  top: 350rpx; left: 200rpx;  }
    50%   {  top: 250rpx; left: 450rpx;  }
    62.5% {  top: 400rpx; left: 150rpx;  }
    75%   {  top: 150rpx; left: 350rpx;  }
    87.5% {  top: 500rpx; left: 250rpx;  }
    100%  {  top: 577rpx; left: 462rpx;  }
  }

  @keyframes around10 {
    0%    {  top: 537rpx; left: 340rpx;  }
    15%   {  top: 400rpx; left: 150rpx;  }
    30%   {  top: 350rpx; left: 450rpx;  }
    50%   {   top: 50rpx; left: 50rpx;   }
    70%   {  top: 450rpx; left: 400rpx;  }
    85%   {  top: 550rpx; left: 120rpx;  }
    100%  {  top: 537rpx; left: 340rpx;  }
  }

  @keyframes around11 {
    0%   { top: 480rpx; left: 333rpx; }
    16%  { top: 350rpx; left: 464rpx; }
    33%  { top: 400rpx; left: 200rpx; }
    50%  { top: 200rpx; left: 400rpx; }
    66%  { top: 300rpx; left: 100rpx; }
    83%  { top: 100rpx; left: 300rpx; }
    100% { top: 480rpx; left: 333rpx; }
  }

  .ball_0{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball0.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .ball_1{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball1.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .ball_2{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball2.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .ball_3{
    background-image: url('https://gitee.com/jingkunxu/img/raw/master/note/blog/ball3.png');
    background-repeat: no-repeat;
    background-size: 100%;
  }
}

</style>

到了这里,关于前端小程序,手把手教你从零开始做一个酷炫的扭蛋机十连抽动画效果的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 手把手教你从0搭建SpringBoot项目

    用到的工具:idea 2021、Maven 3.6.3、postman 框架:SpringBoot、Mybatis 数据库:Mysql8.0.30 安装配置参考博文 注意: 1.下载maven注意idea与Maven版本的适配: 2.为了避免每次创建项目都要改Maven配置,可以修改idea创建新项目的设置 二、安装数据库 mysql8安装参考博文 **注意:**连接不上往

    2024年02月03日
    浏览(49)
  • 手把手教你从零搭建ChatGPT网站AI绘画系统,(SparkAi系统V6)GPTs应用、DALL-E3文生图、AI换脸、垫图混图、SunoAI音乐生成

    SparkAi创作系统是基于ChatGPT进行开发的Ai智能问答系统和Midjourney绘画系统,支持OpenAI-GPT全模型+国内AI全模型。本期针对源码系统整体测试下来非常完美,那么如何搭建部署AI创作ChatGPT?小编这里写一个详细图文教程吧。已支持GPTs、GPT语音对话、GPT-4模型、GPT联网提问、DALL-E

    2024年04月17日
    浏览(46)
  • 手把手教你从入门到精通C# Socket通信

    Socket通信(包含Tcp/Udp通信)在工业领域用途非常广泛,作者在自动化领域耕耘多年,做过的Tcp/Udp通信的项目大大小小也有几百个,公司项目+兼职项目,可以说只要是Tcp/Udp的项目,没有我做不了的,毕竟让我徒手撸一个市面上你见到的Tcp/Udp调试助手对我而言也不在话下,比

    2024年03月17日
    浏览(55)
  • 手把手教你从入门到精通C# Modbus通信

    在工业通信领域,Modbus通信是一种使用非常广泛的通信协议,Modbus一般有三种,分别为ModbusRTU、ModbusASCII、ModbusTCP,其中ModbusRTU、ModbusASCII是应用于串行链路上的协议,通俗一点就是说它是走串口的,ModbusTCP通俗点说就是它是走网口的,作者在自动化领域耕耘多年,做过的Mo

    2024年02月14日
    浏览(54)
  • YOLOv5入门实践(5)——从零开始,手把手教你训练自己的目标检测模型(包含pyqt5界面)

      通过前几篇文章,相信大家已经学会训练自己的数据集了。本篇是YOLOv5入门实践系列的最后一篇,也是一篇总结,我们再来一起按着 配置环境--标注数据集--划分数据集--训练模型--测试模型--推理模型 的步骤,从零开始,一起实现自己的目标检测模型吧! 前期回顾: YOLO

    2023年04月26日
    浏览(63)
  • 【【手把手教你从SD卡驱动VDMA显示图片实验】】

    典型的BMP图像文件是由四部分组成的 包括了BMP的文件头,BMP信息头,调色板,位图数据 因为传递的是RGB图像 RGB不太需要调色板了 从信息头直接到位图数据 文件头占据了14个字节 分别是 查看这个16进制格式 BMP的文件格式 总是低字节的放在低地址位,高字节放在高地址位。

    2024年01月21日
    浏览(55)
  • 手把手教你从微软官网上下载系统镜像【保持最新版】

    🔥推荐阅读:http://t.csdn.cn/nQfIY🔥 🥇个人主页:@MIKE笔记 🥈专栏:爱倒腾 如何从微软官网下载到全系列的系统镜像: 有人可能会说,都有镜像下载工具了,还有了解如何从微软官网直接下载镜像的必要吗? MIKE笔记认为,不仅要知其然,还要知其所以然,掌握这些电脑技巧

    2024年02月04日
    浏览(58)
  • 从零开始手把手学习Pyspark

    作者:禅与计算机程序设计艺术 Apache Spark是由加州大学伯克利分校AMP实验室开发的一个开源大数据处理框架。它基于Hadoop MapReduce计算模型实现,可以有效地处理海量数据并将结果存储到外部系统或数据库中。Spark提供高性能、可扩展性、容错性和易用性等优点。在大数据分析

    2024年02月07日
    浏览(45)
  • 【Oracle安装及使用】超级详细的初次在python中使用Oracl图文详解!手把手教你从安装Oracle到在python中连接Oracle!

    需要安装pycharm、 主要任务:安装Oracle、Oracle环境配置、新建数据库、测试、pycharm中下载包、pycharm中使用Oracle。 1.Oracle完整安装详解 这篇很详细了,还附了安装包,按照博主一套下来基本没问题。 此外有几个小改动: (1)HOST我改成了IP地址 * 查询自己的本机地址方式如下

    2024年02月01日
    浏览(50)
  • 用Python手把手教你实现一个爬虫(含前端界面)

    前言 爬虫基本原理 使用Python的requests库发送HTTP请求 使用BeautifulSoup库解析HTML页面 使用PyQt5构建前端界面 实现一个完整的爬虫程序 结语 随着互联网的飞速发展,再加上科技圈的技术翻天覆地的革新,互联网上每天都会产生海量的数据,这些数据对于企业和个人都具有重要的

    2024年04月28日
    浏览(49)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包