yfs/components/prize-wheel/prize-wheel.vue
2025-05-15 15:33:32 +08:00

498 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>