Vue3 中用 canvas 封装抽奖转盘组件:设定中奖概率及奖项图标和名称

在 Web 应用开发中,抽奖功能是提升用户参与度的常用手段。使用 Vue3 结合 canvas 技术,我们可以轻松实现一个高度自定义的抽奖转盘组件,不仅能设定中奖概率,还能灵活配置奖项图标和名称。本文将详细介绍该组件的实现原理、步骤,并提供完整代码。

实现原理

抽奖转盘组件的核心在于通过 canvas 绘制转盘图形,并结合动画效果实现转盘的旋转。通过设定不同奖项的中奖概率,随机选择中奖奖项,并使用缓动函数控制转盘旋转的动画效果,使转盘最终停在对应的中奖区域。

实现步骤

1. 定义组件模板

在 Vue3 的单文件组件(.vue)中,定义抽奖转盘组件的模板。模板包含 canvas 画布用于绘制转盘,一个中心按钮用于触发抽奖,以及一个提示框用于展示中奖结果。

<template>

<div class="lottery-container">

<div class="wheel-container">

<canvas ref="canvasRef" width="350" height="350"></canvas>

<div class="wheel-center" @click="startLottery">

<div class="start-btn">{{ isRotating? '抽奖中...' : '开始抽奖' }}</div>

</div>

<div class="pointer"></div>

</div>

<div v-if="result" class="result-modal">

<h3>恭喜您获得: {{ result.name }}</h3>

<button @click="result = null">确定</button>

</div>

</div>

</template>

2. 定义组件属性和数据

使用defineProps定义组件接收的属性,包括奖项配置(prizes)、抽奖持续时间(duration)、转盘颜色(colors)。同时,使用ref定义组件内部状态,如是否正在旋转(isRotating)、旋转角度(rotationAngle)、选中的奖项索引(selectedPrizeIndex)、中奖结果(result)等。

import { ref, onMounted, onBeforeUnmount, watch } from 'vue'

const props = defineProps({

prizes: {

type: Array,

required: true,

validator: (value) => {

return value.length >= 2 && value.every(item =>

item.name && typeof item.probability === 'number' && item.probability >= 0

)

}

},

duration: {

type: Number,

default: 5000

},

colors: {

type: Array,

default: () => [

'#FF5252', '#FF4081', '#E040FB', '#7C4DFF',

'#536DFE', '#448AFF', '#40C4FF', '#18FFFF',

'#64FFDA', '#69F0AE', '#B2FF59', '#EEFF41',

'#FFFF00', '#FFD740', '#FFAB40', '#FF6E40'

]

}

})

const emit = defineEmits(['start', 'end'])

const canvasRef = ref(null)

const isRotating = ref(false)

const rotationAngle = ref(0)

const selectedPrizeIndex = ref(-1)

const result = ref(null)

const loadedImages = ref({})

let animationFrameId = null

let startTime = 0

3. 预加载图片

如果奖项配置中有图标,需要提前加载图片,确保绘制转盘时图标能正常显示。通过Image对象加载图片,并在onload事件中触发转盘绘制。

const loadImages = () => {

props.prizes.forEach((prize, index) => {

if (prize.icon) {

const img = new Image()

img.src = prize.icon

img.onload = () => {

loadedImages.value[index] = img

drawWheel()

}

img.onerror = () => {

console.error(`图片加载失败: ${prize.icon}`)

}

}

})

}

4. 绘制转盘

获取 canvas 上下文,根据奖项数量计算每个扇形的角度,绘制转盘的扇形区域,并在每个扇形区域绘制奖项名称和图标。

const drawWheel = () => {

const canvas = canvasRef.value

if (!canvas) return

const ctx = canvas.getContext('2d')

const centerX = canvas.width / 2

const centerY = canvas.height / 2

const radius = Math.min(centerX, centerY) - 10

const arc = Math.PI * 2 / props.prizes.length

ctx.clearRect(0, 0, canvas.width, canvas.height)

// 绘制扇形

props.prizes.forEach((prize, index) => {

const startAngle = index * arc + rotationAngle.value

const endAngle = (index + 1) * arc + rotationAngle.value

ctx.beginPath()

ctx.fillStyle = props.colors[index % props.colors.length]

ctx.moveTo(centerX, centerY)

ctx.arc(centerX, centerY, radius, startAngle, endAngle)

ctx.closePath()

ctx.fill()

// 绘制文字和图标

ctx.save()

ctx.translate(centerX, centerY)

ctx.rotate(startAngle + arc / 2)

// 绘制文字

ctx.fillStyle = '#fff'

ctx.font = 'bold 14px Arial'

ctx.textAlign = 'center'

ctx.fillText(prize.name, radius * 0.7, 5)

// 绘制图标

if (loadedImages.value[index]) {

const img = loadedImages.value[index]

ctx.drawImage(img, radius * 0.5 - 15, -15, 30, 30)

} else if (prize.icon) {

ctx.fillStyle = 'rgba(255,255,255,0.7)'

ctx.fillRect(radius * 0.5 - 15, -15, 30, 30)

}

ctx.restore()

})

}

5. 开始抽奖和动画效果

实现startLottery方法用于开始抽奖,通过selectPrizeByProbability方法根据概率选择中奖奖项,然后使用animateRotation方法实现转盘旋转的动画效果。动画效果中使用缓动函数控制旋转速度,使转盘先快后慢最终停在中奖区域。

const startLottery = () => {

if (isRotating.value) return

emit('start')

isRotating.value = true

result.value = null

selectedPrizeIndex.value = selectPrizeByProbability()

startTime = performance.now()

animateRotation()

}

const animateRotation = () => {

const now = performance.now()

const elapsed = now - startTime

const progress = Math.min(elapsed / props.duration, 1)

// 缓动函数 - 先快后慢

const easeOut = 1 - Math.pow(1 - progress, 4)

// 计算旋转角度

const anglePerItem = Math.PI * 2 / props.prizes.length

const fullCircles = 5 // 完整旋转圈数

// 关键修正:减去Math.PI/2(90度)来校准初始位置

const targetAngle = fullCircles * Math.PI * 2 +

(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -

(anglePerItem / 2) -

Math.PI / 2

rotationAngle.value = easeOut * targetAngle

drawWheel()

if (progress < 1) {

animationFrameId = requestAnimationFrame(animateRotation)

} else {

// 确保最终角度精确对准奖项中心

rotationAngle.value = fullCircles * Math.PI * 2 +

(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -

(anglePerItem / 2) -

Math.PI / 2

drawWheel()

isRotating.value = false

emit('end', props.prizes[selectedPrizeIndex.value])

result.value = props.prizes[selectedPrizeIndex.value]

}

}

6. 根据概率选择奖项

实现selectPrizeByProbability方法,计算所有奖项的总概率,然后生成一个随机数,根据随机数落在哪个概率区间来确定中奖奖项。

const selectPrizeByProbability = () => {

const totalProbability = props.prizes.reduce((sum, prize) => sum + prize.probability, 0)

const random = Math.random() * totalProbability

let currentSum = 0

for (let i = 0; i < props.prizes.length; i++) {

currentSum += props.prizes[i].probability

if (random <= currentSum) {

return i

}

}

return 0

}

7. 生命周期钩子和监听器

在onMounted钩子中调用loadImages和drawWheel初始化转盘;在onBeforeUnmount钩子中取消动画帧,避免内存泄漏;使用watch监听props.prizes的变化,重新加载图片并绘制转盘。

onMounted(() => {

loadImages()

drawWheel()

})

onBeforeUnmount(() => {

if (animationFrameId) {

cancelAnimationFrame(animationFrameId)

}

})

watch(() => props.prizes, () => {

loadedImages.value = {}

loadImages()

drawWheel()

}, { deep: true })

8. 定义组件样式

通过 CSS 样式定义抽奖转盘组件的外观,包括转盘容器、中心按钮、指针和中奖结果提示框的样式。

.lottery-container {

display: flex;

flex-direction: column;

align-items: center;

}

.wheel-container {

position: relative;

width: 350px;

height: 350px;

margin: 0 auto;

}

canvas {

width: 100%;

height: 100%;

border-radius: 50%;

box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);

}

.wheel-center {

position: absolute;

width: 80px;

height: 80px;

background: white;

border-radius: 50%;

top: 50%;

left: 50%;

transform: translate(-50%, -50%);

display: flex;

align-items: center;

justify-content: center;

cursor: pointer;

z-index: 10;

box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);

border: 2px solid #e74c3c;

}

.start-btn {

font-size: 16px;

font-weight: bold;

color: #e74c3c;

text-align: center;

user-select: none;

}

.pointer {

position: absolute;

width: 40px;

height: 40px;

top: -20px;

left: 50%;

transform: translateX(-50%);

z-index: 10;

}

.pointer::before {

content: '';

position: absolute;

border-width: 15px;

border-style: solid;

border-color: transparent transparent #e74c3c transparent;

top: 0;

left:7px;

}

.result-modal {

position: fixed;

top: 50%;

left: 50%;

transform: translate(-50%, -50%);

background: white;

padding: 30px;

border-radius: 10px;

box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);

z-index: 100;

text-align: center;

}

.result-modal h3 {

margin-top: 0;

color: #e74c3c;

}

.result-modal button {

margin-top: 20px;

padding: 10px 20px;

background: #e74c3c;

color: white;

border: none;

border-radius: 5px;

cursor: pointer;

font-size: 16px;

}

完整代码

<template>
  <div class="lottery-container">
    <div class="wheel-container">
      <canvas ref="canvasRef" width="350" height="350"></canvas>
      <div class="wheel-center" @click="startLottery">
        <div class="start-btn">{{ isRotating ? '抽奖中...' : '开始抽奖' }}</div>
      </div>
      <div class="pointer"></div>
    </div>
    
    <div v-if="result" class="result-modal">
      <h3>恭喜您获得: {{ result.name }}</h3>
      <button @click="result = null">确定</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'

const props = defineProps({
  prizes: {
    type: Array,
    required: true,
    validator: (value) => {
      return value.length >= 2 && value.every(item => 
        item.name && typeof item.probability === 'number' && item.probability >= 0
      )
    }
  },
  duration: {
    type: Number,
    default: 5000
  },
  colors: {
    type: Array,
    default: () => [
      '#FF5252', '#FF4081', '#E040FB', '#7C4DFF',
      '#536DFE', '#448AFF', '#40C4FF', '#18FFFF',
      '#64FFDA', '#69F0AE', '#B2FF59', '#EEFF41',
      '#FFFF00', '#FFD740', '#FFAB40', '#FF6E40'
    ]
  }
})

const emit = defineEmits(['start', 'end'])

const canvasRef = ref(null)
const isRotating = ref(false)
const rotationAngle = ref(0)
const selectedPrizeIndex = ref(-1)
const result = ref(null)
const loadedImages = ref({})
let animationFrameId = null
let startTime = 0

// 预加载所有图片
const loadImages = () => {
  props.prizes.forEach((prize, index) => {
    if (prize.icon) {
      const img = new Image()
      img.src = prize.icon
      img.onload = () => {
        loadedImages.value[index] = img
        drawWheel()
      }
      img.onerror = () => {
        console.error(`图片加载失败: ${prize.icon}`)
      }
    }
  })
}

// 绘制转盘
const drawWheel = () => {
  const canvas = canvasRef.value
  if (!canvas) return
  
  const ctx = canvas.getContext('2d')
  const centerX = canvas.width / 2
  const centerY = canvas.height / 2
  const radius = Math.min(centerX, centerY) - 10
  const arc = Math.PI * 2 / props.prizes.length
  
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  
  // 绘制扇形
  props.prizes.forEach((prize, index) => {
    const startAngle = index * arc + rotationAngle.value
    const endAngle = (index + 1) * arc + rotationAngle.value
    
    ctx.beginPath()
    ctx.fillStyle = props.colors[index % props.colors.length]
    ctx.moveTo(centerX, centerY)
    ctx.arc(centerX, centerY, radius, startAngle, endAngle)
    ctx.closePath()
    ctx.fill()
    
    // 绘制文字和图标
    ctx.save()
    ctx.translate(centerX, centerY)
    ctx.rotate(startAngle + arc / 2)
    
    // 绘制文字
    ctx.fillStyle = '#fff'
    ctx.font = 'bold 14px Arial'
    ctx.textAlign = 'center'
    ctx.fillText(prize.name, radius * 0.7, 5)
    
    // 绘制图标
    if (loadedImages.value[index]) {
      const img = loadedImages.value[index]
      ctx.drawImage(img, radius * 0.5 - 15, -15, 30, 30)
    } else if (prize.icon) {
      ctx.fillStyle = 'rgba(255,255,255,0.7)'
      ctx.fillRect(radius * 0.5 - 15, -15, 30, 30)
    }
    
    ctx.restore()
  })
}

// 开始抽奖
const startLottery = () => {
  if (isRotating.value) return
  
  emit('start')
  isRotating.value = true
  result.value = null
  selectedPrizeIndex.value = selectPrizeByProbability()
  
  startTime = performance.now()
  animateRotation()
}

// 动画函数
const animateRotation = () => {
  const now = performance.now()
  const elapsed = now - startTime
  const progress = Math.min(elapsed / props.duration, 1)
  
  // 缓动函数 - 先快后慢
  const easeOut = 1 - Math.pow(1 - progress, 4)
  
  // 计算旋转角度
  const anglePerItem = Math.PI * 2 / props.prizes.length
  const fullCircles = 5 // 完整旋转圈数
  
  // 关键修正:减去Math.PI/2(90度)来校准初始位置
  const targetAngle = fullCircles * Math.PI * 2 + 
                     (Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) - 
                     (anglePerItem / 2) - 
                     Math.PI / 2
  
  rotationAngle.value = easeOut * targetAngle
  drawWheel()
  
  if (progress < 1) {
    animationFrameId = requestAnimationFrame(animateRotation)
  } else {
    // 确保最终角度精确对准奖项中心
    rotationAngle.value = fullCircles * Math.PI * 2 + 
                         (Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) - 
                         (anglePerItem / 2) - 
                         Math.PI / 2
    drawWheel()
    
    isRotating.value = false
    emit('end', props.prizes[selectedPrizeIndex.value])
    result.value = props.prizes[selectedPrizeIndex.value]
  }
}

// 根据概率选择奖项
const selectPrizeByProbability = () => {
  const totalProbability = props.prizes.reduce((sum, prize) => sum + prize.probability, 0)
  const random = Math.random() * totalProbability
  
  let currentSum = 0
  for (let i = 0; i < props.prizes.length; i++) {
    currentSum += props.prizes[i].probability
    if (random <= currentSum) {
      return i
    }
  }
  return 0
}

// 初始化
onMounted(() => {
  loadImages()
  drawWheel()
})

onBeforeUnmount(() => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId)
  }
})

watch(() => props.prizes, () => {
  loadedImages.value = {}
  loadImages()
  drawWheel()
}, { deep: true })
</script>

<style scoped>
.lottery-container {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.wheel-container {
  position: relative;
  width: 350px;
  height: 350px;
  margin: 0 auto;
}

canvas {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
}

.wheel-center {
  position: absolute;
  width: 80px;
  height: 80px;
  background: white;
  border-radius: 50%;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 10;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
  border: 2px solid #e74c3c;
}

.start-btn {
  font-size: 16px;
  font-weight: bold;
  color: #e74c3c;
  text-align: center;
  user-select: none;
}

.pointer {
  position: absolute;
  width: 40px;
  height: 40px;
  top: -20px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
}

.pointer::before {
  content: '';
  position: absolute;
  border-width: 15px;
  border-style: solid;
  border-color: transparent transparent #e74c3c transparent;
  top: 0;
  left:7px;
}

.result-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 30px;
  border-radius: 10px;
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
  z-index: 100;
  text-align: center;
}

.result-modal h3 {
  margin-top: 0;
  color: #e74c3c;
}

.result-modal button {
  margin-top: 20px;
  padding: 10px 20px;
  background: #e74c3c;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 16px;
}
</style>

在父组件中prizes的结构为:

[
    { name: '一等奖', probability: 5, icon: './public/icons/jiang.png' },
    { name: '二等奖', probability: 10, icon: './public/icons/jiang.png' },
    { name: '三等奖', probability: 15, icon: './public/icons/jiang.png' },
    { name: '四等奖', probability: 20, icon: './public/icons/jiang.png' },
    { name: '五等奖', probability: 25, icon: './public/icons/jiang.png' },
    { name: '谢谢参与', probability: 25 }
  ]

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jiaberrr

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值