498 lines
14 KiB
Vue
498 lines
14 KiB
Vue
<template>
|
||
<view class="prize-wheel">
|
||
<view class="prize-container" :style="containerStyle">
|
||
<view class="prize-strip" :style="stripStyle">
|
||
<!-- 左侧复制项 -->
|
||
<view
|
||
class="prize-item"
|
||
v-for="(item, index) in visibleItems.prefix"
|
||
:key="`prefix-${index}`"
|
||
:style="itemStyle"
|
||
>
|
||
<slot :item="item">
|
||
<view class="default-prize" :style="{ color: item.color }">{{item.value}}</view>
|
||
</slot>
|
||
</view>
|
||
|
||
<!-- 主要项 -->
|
||
<view
|
||
class="prize-item"
|
||
v-for="(item, index) in visibleItems.main"
|
||
:key="`main-${index}`"
|
||
:class="{'prize-active': isActive && activeIndex === index}"
|
||
:style="itemStyle"
|
||
>
|
||
<slot :item="item">
|
||
<view class="default-prize" :style="{ color: item.color }">{{item.value}}</view>
|
||
</slot>
|
||
</view>
|
||
|
||
<!-- 右侧复制项 -->
|
||
<view
|
||
class="prize-item"
|
||
v-for="(item, index) in visibleItems.suffix"
|
||
:key="`suffix-${index}`"
|
||
:style="itemStyle"
|
||
>
|
||
<slot :item="item">
|
||
<view class="default-prize" :style="{ color: item.color }">{{item.value}}</view>
|
||
</slot>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 中心指示器 -->
|
||
<view class="center-indicator"></view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: 'PrizeWheel',
|
||
props: {
|
||
// 奖品列表
|
||
prizes: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
// 动画时长(秒)
|
||
duration: {
|
||
type: Number,
|
||
default: 4
|
||
},
|
||
// 缓冲数量(前后各多少个)
|
||
bufferCount: {
|
||
type: Number,
|
||
default: 5
|
||
},
|
||
// 单个奖品宽度
|
||
itemWidth: {
|
||
type: Number,
|
||
default: () => uni.upx2px(180)
|
||
},
|
||
// 单个奖品高度
|
||
itemHeight: {
|
||
type: Number,
|
||
default: () => uni.upx2px(150)
|
||
},
|
||
// 是否高亮显示选中项
|
||
highlight: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 最大速度
|
||
maxSpeed: {
|
||
type: Number,
|
||
default: 2000
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
isSpinning: false, // 是否正在旋转
|
||
isActive: false, // 是否高亮显示
|
||
activeIndex: -1, // 高亮索引
|
||
currentOffset: 0, // 当前偏移量
|
||
targetPrize: null, // 目标奖品
|
||
targetIndex: -1, // 目标奖品索引
|
||
speedPhase: 'initial', // 速度阶段:initial, accelerating, constant, decelerating, stopping
|
||
animationId: null, // 动画ID
|
||
lastTimestamp: 0, // 上次时间戳
|
||
speed: 0, // 当前速度(px/s)
|
||
totalSpinCount: 0, // 总共旋转次数
|
||
itemsCount: 0, // 实际奖品数量
|
||
autoResetTimer: null, // 自动重置定时器
|
||
}
|
||
},
|
||
computed: {
|
||
// 计算可视奖品列表(前缀+主体+后缀)
|
||
visibleItems() {
|
||
if (!this.prizes || this.prizes.length === 0) {
|
||
return { prefix: [], main: [], suffix: [] }
|
||
}
|
||
|
||
// 基本项数
|
||
const baseItems = [...this.prizes]
|
||
this.itemsCount = baseItems.length
|
||
|
||
// 主体区域重复数次以填满屏幕
|
||
let mainItems = []
|
||
const repeatCount = Math.ceil(30 / baseItems.length) // 确保足够多的项目
|
||
for (let i = 0; i < repeatCount; i++) {
|
||
mainItems = [...mainItems, ...baseItems]
|
||
}
|
||
|
||
// 前缀和后缀(与主体数据相同,用于无缝循环)
|
||
const prefix = [...baseItems]
|
||
const suffix = [...baseItems]
|
||
|
||
return {
|
||
prefix,
|
||
main: mainItems,
|
||
suffix
|
||
}
|
||
},
|
||
|
||
// 容器样式
|
||
containerStyle() {
|
||
return {
|
||
height: `${this.itemHeight}px`,
|
||
width: '100%',
|
||
overflow: 'hidden'
|
||
}
|
||
},
|
||
|
||
// 奖品条带样式
|
||
stripStyle() {
|
||
let transitionStyle = this.isSpinning ? 'none' : `transform ${this.duration / 3}s cubic-bezier(0.34, 1.56, 0.64, 1)`
|
||
return {
|
||
transform: `translateX(${this.currentOffset}px)`,
|
||
transition: transitionStyle
|
||
}
|
||
},
|
||
|
||
// 单个项目样式
|
||
itemStyle() {
|
||
return {
|
||
width: `${this.itemWidth}px`,
|
||
height: `${this.itemHeight}px`,
|
||
flexShrink: 0
|
||
}
|
||
},
|
||
|
||
// 计算中心位置偏移量
|
||
centerOffset() {
|
||
const containerWidth = uni.getSystemInfoSync().windowWidth
|
||
return containerWidth / 2 - this.itemWidth / 2
|
||
},
|
||
|
||
// 单个周期的宽度(一组奖品的总宽度)
|
||
cycleWidth() {
|
||
return this.itemWidth * this.prizes.length
|
||
}
|
||
},
|
||
mounted() {
|
||
// 初始化位置到中心
|
||
this.resetPosition()
|
||
},
|
||
beforeDestroy() {
|
||
// 清理资源
|
||
this.stopAnimation()
|
||
if (this.autoResetTimer) {
|
||
clearTimeout(this.autoResetTimer)
|
||
}
|
||
},
|
||
methods: {
|
||
// 重置位置到中心
|
||
resetPosition() {
|
||
// 计算初始偏移量,确保一个完整的周期显示在中间
|
||
this.currentOffset = this.centerOffset - (this.prizes.length * this.itemWidth)
|
||
},
|
||
|
||
// 开始抽奖
|
||
startSpin() {
|
||
if (this.isSpinning) return
|
||
|
||
this.isSpinning = true
|
||
this.speedPhase = 'accelerating'
|
||
this.speed = 30 // 初始速度
|
||
this.targetPrize = null
|
||
this.targetIndex = -1
|
||
this.totalSpinCount = 0
|
||
this.lastTimestamp = performance.now()
|
||
|
||
// 发出开始事件
|
||
this.$emit('spin-start')
|
||
|
||
// 启动动画循环
|
||
this.animationId = requestAnimationFrame(this.animate)
|
||
},
|
||
|
||
// 设置最终奖品并开始减速
|
||
setPrize(prize) {
|
||
if (!this.isSpinning) return
|
||
|
||
// 保存目标奖品
|
||
this.targetPrize = prize
|
||
|
||
// 查找目标奖品在列表中的位置
|
||
this.targetIndex = this.prizes.findIndex(item =>
|
||
(item.id && item.id === prize.id) ||
|
||
(item.value && item.value === prize.value)
|
||
)
|
||
|
||
if (this.targetIndex === -1) {
|
||
console.warn('目标奖品不在奖品列表中')
|
||
// 如果找不到,默认使用第一个
|
||
this.targetIndex = 0
|
||
}
|
||
|
||
// 计划减速
|
||
setTimeout(() => {
|
||
if (this.isSpinning) {
|
||
this.speedPhase = 'decelerating'
|
||
}
|
||
}, 1000) // 匀速运行一段时间后开始减速
|
||
},
|
||
|
||
// 动画函数
|
||
animate(timestamp) {
|
||
if (!this.isSpinning) return
|
||
|
||
// 计算时间差
|
||
const delta = timestamp - this.lastTimestamp
|
||
this.lastTimestamp = timestamp
|
||
|
||
// 根据阶段更新速度
|
||
switch(this.speedPhase) {
|
||
case 'accelerating':
|
||
// 加速阶段
|
||
this.speed = Math.min(this.maxSpeed, this.speed * 1.08)
|
||
if (this.speed >= this.maxSpeed) {
|
||
this.speedPhase = 'constant'
|
||
}
|
||
break
|
||
|
||
case 'decelerating':
|
||
// 减速阶段
|
||
this.speed *= 0.97
|
||
|
||
// 当速度足够慢且有目标奖品时,准备停止
|
||
if (this.speed < 200 && this.targetIndex !== -1) {
|
||
this.speedPhase = 'stopping'
|
||
this.prepareToStop()
|
||
}
|
||
break
|
||
|
||
case 'stopping':
|
||
// 停止阶段 - 速度进一步降低
|
||
this.speed *= 0.85
|
||
|
||
// 当速度非常慢时完全停止
|
||
if (this.speed < 10) {
|
||
this.finalizePosition()
|
||
this.stopAnimation()
|
||
return
|
||
}
|
||
break
|
||
}
|
||
|
||
// 移动奖品条
|
||
this.moveStrip(delta)
|
||
|
||
// 继续动画
|
||
this.animationId = requestAnimationFrame(this.animate)
|
||
},
|
||
|
||
// 移动奖品条
|
||
moveStrip(deltaTime) {
|
||
// 计算移动距离
|
||
const distance = (this.speed * deltaTime) / 1000
|
||
|
||
// 更新位置
|
||
this.currentOffset -= distance
|
||
|
||
// 检查是否需要重置位置(循环效果)
|
||
this.checkResetPosition()
|
||
|
||
// 计算当前中心位置的项
|
||
this.updateActiveItem()
|
||
},
|
||
|
||
// 检查并重置位置以实现无限循环
|
||
checkResetPosition() {
|
||
const cycleWidth = this.itemWidth * this.prizes.length
|
||
|
||
// 如果滚动过远,重置到等效位置
|
||
if (Math.abs(this.currentOffset) > cycleWidth * 3) {
|
||
// 计算偏移量对一个周期的余数
|
||
const remainder = this.currentOffset % cycleWidth
|
||
// 重置位置,保持视觉上的连续性
|
||
this.currentOffset = this.centerOffset - cycleWidth + remainder
|
||
this.totalSpinCount++
|
||
}
|
||
},
|
||
|
||
// 更新活跃项
|
||
updateActiveItem() {
|
||
if (!this.highlight) return
|
||
|
||
// 计算当前中心位置对应的项
|
||
const relativePos = -this.currentOffset + this.centerOffset
|
||
// 计算相对于一个周期的位置
|
||
const cyclePos = (relativePos % this.cycleWidth + this.cycleWidth) % this.cycleWidth
|
||
// 计算对应项的索引
|
||
const itemIndex = Math.floor(cyclePos / this.itemWidth)
|
||
|
||
if (itemIndex >= 0 && itemIndex < this.prizes.length) {
|
||
this.isActive = true
|
||
this.activeIndex = itemIndex
|
||
} else {
|
||
this.isActive = false
|
||
}
|
||
},
|
||
|
||
// 准备停止动画,对准目标奖品
|
||
prepareToStop() {
|
||
if (this.targetIndex === -1) return
|
||
|
||
// 计算当前位置
|
||
const relativePos = -this.currentOffset + this.centerOffset
|
||
// 当前位置在一个周期内的偏移量
|
||
const cyclePos = (relativePos % this.cycleWidth + this.cycleWidth) % this.cycleWidth
|
||
// 当前项的索引
|
||
const currentItemIndex = Math.floor(cyclePos / this.itemWidth)
|
||
|
||
// 计算需要移动的距离,确保目标奖品最终落在中心
|
||
// 这里的计算考虑了当前减速状态下的停止位置
|
||
|
||
// 减速率因子 - 影响最终停止位置的精度
|
||
const decelerationFactor = 0.95
|
||
|
||
// 计算目标索引相对于当前索引的位置
|
||
// 确保我们总是向前滚动到目标(不会反向)
|
||
let stepsToTarget = this.targetIndex - currentItemIndex
|
||
if (stepsToTarget <= 0) {
|
||
stepsToTarget += this.prizes.length
|
||
}
|
||
|
||
// 根据当前速度微调速度,确保能够准确停在目标奖品上
|
||
// 这是一个关键的计算,需要考虑减速曲线
|
||
if (this.speed > 100) {
|
||
this.speed = Math.max(100, this.speed * decelerationFactor)
|
||
}
|
||
},
|
||
|
||
// 最终化位置,确保奖品对准中心
|
||
finalizePosition() {
|
||
if (this.targetIndex === -1) return
|
||
|
||
// 计算精确位置使目标奖品居中
|
||
// 注意:这里不再使用额外的周期位移,直接计算目标位置
|
||
const relativePos = -this.currentOffset + this.centerOffset
|
||
const cyclePos = (relativePos % this.cycleWidth + this.cycleWidth) % this.cycleWidth
|
||
const currentItemIndex = Math.floor(cyclePos / this.itemWidth)
|
||
|
||
// 计算最短路径到目标奖品
|
||
let adjustedOffset = this.currentOffset
|
||
|
||
// 如果目标就在附近,直接微调位置
|
||
if (Math.abs(currentItemIndex - this.targetIndex) <= this.prizes.length / 2) {
|
||
const delta = (currentItemIndex - this.targetIndex) * this.itemWidth
|
||
adjustedOffset += delta
|
||
}
|
||
|
||
// 使用CSS过渡平滑地移动到最终位置
|
||
this.isSpinning = false
|
||
this.currentOffset = adjustedOffset
|
||
|
||
// 设置活跃项
|
||
this.isActive = true
|
||
this.activeIndex = this.targetIndex
|
||
},
|
||
|
||
// 停止动画
|
||
stopAnimation() {
|
||
if (!this.isSpinning && !this.animationId) return
|
||
|
||
cancelAnimationFrame(this.animationId)
|
||
this.animationId = null
|
||
this.isSpinning = false
|
||
|
||
// 发出停止事件,传递选中的奖品
|
||
this.$emit('spin-end', this.targetPrize || this.prizes[this.activeIndex])
|
||
|
||
// 移除自动重置定时器,让奖品停在当前位置
|
||
if (this.autoResetTimer) {
|
||
clearTimeout(this.autoResetTimer)
|
||
this.autoResetTimer = null
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
.prize-wheel {
|
||
position: relative;
|
||
width: 100%;
|
||
|
||
.prize-container {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.prize-strip {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.prize-item {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
transition: transform 0.2s ease;
|
||
|
||
&.prize-active {
|
||
transform: scale(1.1);
|
||
z-index: 1;
|
||
|
||
.default-prize {
|
||
background-color: rgba(255, 215, 0, 0.3);
|
||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||
}
|
||
}
|
||
}
|
||
|
||
.default-prize {
|
||
width: 90%;
|
||
height: 90%;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
border-radius: 8px;
|
||
font-size: 30rpx;
|
||
padding: 10rpx;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.center-indicator {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 2px;
|
||
height: 100%;
|
||
background-color: #ff5a5f;
|
||
z-index: 10;
|
||
|
||
&:before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 10px solid transparent;
|
||
border-right: 10px solid transparent;
|
||
border-top: 10px solid #ff5a5f;
|
||
}
|
||
|
||
&:after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 10px solid transparent;
|
||
border-right: 10px solid transparent;
|
||
border-bottom: 10px solid #ff5a5f;
|
||
}
|
||
}
|
||
}
|
||
</style> |