乐趣区

关于javascript: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 的视觉和无缝循环成果

别离须要通过:

  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 维度的属性, 咱们都晓得的根本轴分几个

而后偏移值得多少这是一个比较复杂波及到初中数学公式的问题

咱们已知边长为 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 的视角看界面, 咱们理解一下这些要害属性的作用

属性 作用
perspective perspective 属性定义 3D 元素距视图的间隔,以像素计。该属性容许您扭转 3D 元素查看 3D 元素的视图。当为元素定义 perspective 属性时,其子元素会取得透视成果,而不是元素自身。正文:perspective 属性只影响 3D 转换元素。
perspective-origin perspective-origin 属性定义 3D 元素所基于的 X 轴和 Y 轴。该属性容许您扭转 3D 元素的底部地位。当为元素定义 perspective-origin 属性时,其子元素会取得透视成果,而不是元素自身。正文:该属性必须与 perspective 属性一起应用,而且只影响 3D 转换元素。
transform-style 规定如何在 3D 空间中出现被嵌套的元素。

如果接触过设计或者修建等业余大略看过这些图, 总的来说 转移视角就是扭转眼帘角度和间隔.

所以咱们略微调整一下属性, 而后复制多个叠加在一起看看成果

. 主容器{
    perspective: 3000px; //3d 平面空间感
    perspective-origin: 50% 50%; // 察看视角,50% 50% 代表从两头察看
    ul {
      margin: 0 4px;
      transform-style: preserve-3d;
    }
}

咱们能够发现视觉上是 3D 并且居中了, 然而他们层叠优先级的问题须要解决一下, 咱们利用反向计算的伎俩解决

-(以后索引 – 平均值)

ul {
  @for $idx from 1 through $num {&:nth-child(#{$idx}) {z-index: -(#{($idx - 3)});
    }
  }
}

暗藏眼帘

剩下咱们只须要保留一行眼帘的地位暗藏容器外的元素就行了, 须要留神的通过上述几个属性会对容器理论尺寸有影响, 须要预留一个内边距

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

不留边距

预留边距

动画成果

咱们在下面已晓得整圈数字是正十边形, 每个数字的旋转角度也晓得, 所以只须要一个循环即可实现, 咱们给动画一些固定旋转圈数达到更好的成果, 另外还须要设置默认的动画属性, 和按程序延时成果

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>

咱们看一下成果

咱们会发现有两个问题

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

修复动画生效

解决思路就是移除以后的类名, 而后从新赋值, 为了防止两次操作被合并吞掉咱们须要将赋值那一步提早执行, 这里有个抉择

  • 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 能够解决问题?

能够发现咱们曾经能够顺利从新执行动画了, 胜利将问题一转化成问题二

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

最终成果

退出移动版