likes
comments
collection

Vue3版3D老虎机实现思路

作者站长头像
站长
· 阅读数 13

Vue3版3D老虎机实现思路

生成数据

因为老虎机每一列的数据一般都是一致的,所以我们需要有一个默认的初始化数据,在这里我们简化成0-9的数组,动态生成的方式有很多,从长到短实现方式有:

方式一

new Array(10).join(',').split(',').map((item, idx) => idx)

方式二

(Array.from({length:10})).map((item, idx) => idx)

方式三

Array.from({length:10},(item, idx) => idx)

方式四

[...new Array(10).keys()]

元素布局

我们需要应用3D属性进行布局达到3D的视觉和无缝循环效果Vue3版3D老虎机实现思路

分别需要经过:

  1. 绝对定位
  2. X轴旋转
  3. Z轴偏移
  4. 转移视角
  5. 隐藏视线

绝对定位

实现图形一效果

ul {
  position: relative;
  width: 100px;
  height: 160px;
}
li {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border: 1px solid #3e3e3e;
  background-color: rgba(233, 155, 67, 0.1);
}

X轴旋转

我们有十个数字,围绕成正十边形,所以每一个数字的旋转角度也很容易算出来了,实现图形二效果

// 边数
$num: 10;

li {
  @for $idx from 1 through $num {
    &:nth-child(#{$idx}) {
      transform: rotateX(-#{($idx - 1) * 360 / $num}deg);
    }
  }
}

Z轴偏移

首先这是一个3D维度的属性,我们都知道的基本轴分几个Vue3版3D老虎机实现思路

然后偏移值得多少这是一个比较复杂涉及到初中数学公式的问题Vue3版3D老虎机实现思路

我们已知边长为160,各三角形内角度为360/10=36,而r就是我们需要求出来的边心距,也即是Z轴的偏移值了,根据数学公式

r = 直角三角形内角度对边 / Math.tan(直角三角形内角度 / 180 * Math.PI)

r = (160/2) / Math.tan((36 / 2) / 180 * Math.PI) ≈ 246

所以最后的样式

// 边数
$num: 10;

li {
  @for $idx from 1 through $num {
    &:nth-child(#{$idx}) {
      transform: rotateX(-#{($idx - 1) * 360 / $num}deg) translateZ(246px);
    }
  }
}

转移视角

上面三个步骤虽然已经实现布局了,但是没用,我们屏幕就是平面的,而perspective就能决定我们用2D还是3D的视角看界面,我们了解一下这些关键属性的作用

属性作用
perspectiveperspective 属性定义 3D 元素距视图的距离,以像素计。该属性允许您改变 3D 元素查看 3D 元素的视图。当为元素定义 perspective 属性时,其子元素会获得透视效果,而不是元素本身。注释:perspective 属性只影响 3D 转换元素。
perspective-originperspective-origin 属性定义 3D 元素所基于的 X 轴和 Y 轴。该属性允许您改变 3D 元素的底部位置。当为元素定义 perspective-origin 属性时,其子元素会获得透视效果,而不是元素本身。注释:该属性必须与 perspective 属性一同使用,而且只影响 3D 转换元素。
transform-style规定如何在 3D 空间中呈现被嵌套的元素。

如果接触过设计或者建筑等专业大概看过这些图,总的来说转移视角就是改变视线角度和距离.Vue3版3D老虎机实现思路

所以我们稍微调整一下属性,然后复制多个叠加在一起看看效果

.主容器{
    perspective: 3000px; //3d立体空间感
    perspective-origin: 50% 50%; //观察视角, 50% 50%代表从中间观察
    ul {
      margin: 0 4px;
      transform-style: preserve-3d;
    }
}

Vue3版3D老虎机实现思路

我们可以发现视觉上是3D并且居中了,但是他们层叠优先级的问题需要处理一下,我们利用反向计算的手段处理

-(当前索引 - 平均值)
ul {
  @for $idx from 1 through $num {
    &:nth-child(#{$idx}) {
      z-index: -(#{($idx - 3)});
    }
  }
}

Vue3版3D老虎机实现思路

隐藏视线

剩下我们只需要保留一行视线的位置隐藏容器外的元素就行了,需要注意的经过上述几个属性会对容器实际尺寸有影响,需要预留一个内边距

.主容器{
    overflow: hidden;
    width: 600px;
    height: 160px;
    padding: 20px 60px;
}

不留边距

Vue3版3D老虎机实现思路

预留边距

Vue3版3D老虎机实现思路

动画效果

我们在上面已知道整圈数字是正十边形,每个数字的旋转角度也知道,所以只需要一个循环即可完成,我们给动画一些固定旋转圈数达到更好的效果,另外还需要设置默认的动画属性,和按顺序延时效果

ul {
  animation-duration: 2s;
  animation-fill-mode: forwards;
  animation-timing-function: ease-in-out;
  @for $idx from 1 through $num {
    &:nth-child(#{$idx}) {
      animation-delay: #{($idx - 1) * 0.2}s;
    }
    @keyframes num#{($idx - 1)} {
      to {
        transform: rotateX(calc(5 * 360deg + 360deg / #{$num} * #{($idx - 1)}));
      }
    }
  }
}

RollUp组件实现

<template>
  <div class="bandit">
  </div>
</template>

<script setup>
const props = defineProps({
  // 行数量
  col: {
    type: [Number],
    default: 4,
  },
  result: {
    type: [Array],
  },
})
const emit = defineEmits(['onComplete'])
</script>

为了保证通用性,我们选择把样式留给外部实现,通过插槽方式引入,组件只负责三件事:

  • 动态扩展插槽
  • 切换动画
  • 动画完成回调

动态扩展插槽

<template>
  <div class="bandit">
    <slot v-for="(item, idx) in col" :key="idx"></slot>
  </div>
</template>

<script setup>
// ...
</script>

<style lang="scss">
.bandit {
  display: flex;
}
</style>

需要根据传入的数值复制多列插槽,并且水平布局

切换动画

<template>
  <div class="bandit">
    <slot v-for="(item, idx) in col" :key="idx"></slot>
  </div>
</template>

<script setup>
// ...
const banditDom = ref(null)
watch(
  () => props.result,
  (ary) => {
    banditDom.value?.children &&
      Array.from(banditDom.value?.children)?.forEach((item, idx) => {
        item.style.animationName = `num${ary[idx]}`
      })
  },
  { immediate: true }
)
</script>

需要监听传入的结果索引设置该列播放动画,这里需要注意两个地方

  • 因为我们没法直接获取插槽的实例,所以通过获取容器实例修改它下面子元素的样式
  • banditDom.value?.childrenHTMLCollection类型,所以不能直接使用,需要转换成数组

动画完成回调

<template>
  <div class="bandit" ref="banditDom" @animationend="animationend">
    <slot v-for="(item, idx) in col" :key="idx"></slot>
  </div>
</template>

<script setup>
// ...
let count = 0
const animationend = () => {
  count++
  if (count === props.col) {
    count = 0
    emit('onComplete')
  }
}
</script>

因为animationend事件除了自身也能被子元素所触发,所以我们需要等待所有动画都结束之后才触发事件

使用示例

<template>
  <div class="index">
    <Bandit class="index-rollup" :col="banditCol" :result="banditResult" @onComplete="aniEnd">
      <ul>
        <li v-for="item in banditList" :key="item">{{ item }}</li>
      </ul>
    </Bandit>
    <button @click="test">click me</button>
  </div>
</template>

<script setup>
// 数字
const banditList = ref([...new Array(10).keys()])
// 结果
const banditResult = ref(['1', '0', '0', '0', '0'])
// 列
const banditCol = ref(6)
const aniEnd = () => {
  console.log('done')
}
</script>

我们看一下效果Vue3版3D老虎机实现思路

我们会发现有两个问题

  • 如果更新索引跟现在一样的话不会进行动画
  • 更新动画的时候会先重置到初始值再开始

修复动画失效

解决思路就是移除当前的类名,然后重新赋值,为了避免两次操作被合并吞掉我们需要将赋值那一步延迟执行,这里有个选择

  • setTimeout: 在指定的毫秒数后调用函数或计算表达式
  • nextTick: 将回调推迟到下一个 DOM 更新周期之后执行。

分别两种方法都是试用一下,打印他们执行顺序

const banditDom = ref(null)
watch(
  () => props.result,
  (ary) => {
    banditDom.value?.children &&
      Array.from(banditDom.value?.children)?.forEach((item, idx) => {
          console.log(1)
        item.style.animationName = ''
        // 或者 setTimeout
        nextTick(() => {
          item.style.animationName = `num${ary[idx]}`
          console.log(2)
        })
      })
  },
  { immediate: true }
)
onBeforeUpdate(() => {
  console.log(3)
})
onUpdated(() => {
  console.log(4)
})

执行顺序也是一模一样

1 * 6

3

4

2 * 6

但是为什么只有setTimeout可以解决问题?Vue3版3D老虎机实现思路

可以发现我们已经可以顺利重新执行动画了,成功将问题一转化成问题二

setTimeout 和 nextTick 的不同

我们知道从概念上来区分

前者属于宏任务,后者属于微任务,即使执行顺序一样也不代表执行时机相同,我们新增参照物作对比

setTimeout

const banditDom = ref(null)
watch(
  () => props.result,
  (ary) => {
    banditDom.value?.children &&
      Array.from(banditDom.value?.children)?.forEach((item, idx) => {
        item.style.animationName = ''
        Promise.resolve().then(() => console.log('微任务'))
        setTimeout(() => console.log('宏任务'))
        setTimeout(() => {
          item.style.animationName = `num${ary[idx]}`
          console.log(2)
        })
      })
  },
  { immediate: true }
)

微任务 * 6

宏任务

2

宏任务

2

宏任务

2

宏任务

2

宏任务

2

宏任务

2

nextTick

const banditDom = ref(null)
watch(
  () => props.result,
  (ary) => {
    banditDom.value?.children &&
      Array.from(banditDom.value?.children)?.forEach((item, idx) => {
        item.style.animationName = ''
        Promise.resolve().then(() => console.log('微任务'))
        setTimeout(() => console.log('宏任务'))
        nextTick(() => {
          item.style.animationName = `num${ary[idx]}`
          console.log('业务逻辑')
        })
      })
  },
  { immediate: true }
)
微任务 *6业务逻辑 *6宏任务 *6

所以原因大概是中间的变化被合并执行了.更多原因可以了解 https://github.com/vuejs/vue/...

开始动画前的重置问题

首先我们需要知道为什么会出现这个问题,从步骤上来说我们

初始状态 -> 动画 -> 保持最后一个动作 -> 清除动画属性 -> 回到初始状态 -> 赋值类 -> 动画

从流程可以看出如果想解决问题的话只需要记录清空前的动画属性就可以了

因为我们没有办法获取到动画最后一帧的样式,所以我们只能用一些取巧的办法实现,我们现在已知条件可以利用:

  • 上一次结果
  • 边数

组件修改

// 上一次结果
const lastResult = ref({})

// <========== 动画事件 ==========
const banditDom = ref(null)
watch(
  () => props.result,
  (ary, oAry) => {
    lastResult.value = oAry
    banditDom.value?.children &&
      Array.from(banditDom.value?.children)?.forEach((item, idx) => {
        item.setAttribute('class', 'num' + lastResult.value[idx])
        item.style.animationName = ''
        setTimeout(() => {
          item.style.animationName = `num${ary[idx]}`
        })
      })
  },
  { immediate: true }
)

父组件修改

新增最后一帧的样式

ul {
  @for $idx from 1 through $num {
    &.num#{($idx - 1)} {
      transform: rotateX(calc(-5 * 360deg + 360deg / #{$num} * #{($idx - 1)}));
    }
    @keyframes num#{($idx - 1)} {
      to {
        transform: rotateX(calc(5 * 360deg + 360deg / #{$num} * #{($idx - 1)}));
      }
    }
  }
}

扩展优化

有时候我们需要进入页面需要定位上一次的结果,但是这时候需求希望可以跳过动画直接完成,这时候我们需要新增属性判断

const props = defineProps({
  // 行数量
  col: {
    type: [Number],
    default: 4,
  },
  isDelazy: {
    type: [Boolean],
    default: true,
  },
  result: {
    type: [Array],
  },
})

因为直接省略掉了过程,所以赋值方面需要分两步流程,同时跳过动画的时候不会触犯结束事件,所以需要分开处理

const banditDom = ref(null)

// 动画结束需要定位最后一帧样式
const setEndding = () => {
  Array.from(banditDom.value?.children)?.forEach((item, idx) => {
    item.setAttribute('class', 'num' + props.result[idx])
  })
}

watch(
  () => props.result,
  (ary, oAry) => {
    if (!banditDom.value?.children) return

    // 支持动画和直接定位
    if (props.isDelazy) {
      lastResult.value = oAry
      Array.from(banditDom.value?.children)?.forEach((item, idx) => {
        item.style.animationName = ''
        setTimeout(() => {
          item.style.animationName = `num${ary[idx]}`
        })
      })
    } else {
      // 不触发动画,直接定位最后一帧样式
      lastResult.value = ary
      setEndding()
    }
  },
  { immediate: true }
)

同时我们把设置最后一帧的操作放到动画结束后

let count = 0
const animationend = () => {
  count++
  if (count === props.col) {
    count = 0
    setEndding()
    emit('onComplete')
  }
}

最终效果

Vue3版3D老虎机实现思路