中秋来了,抽个奖再走吧~

378 阅读3分钟

前言

又来到一年一度的中秋节,大家最近收到月饼了吗?如果没有,那你要好好反思下:这么多年下来,有没有好好工作?好好谈个恋爱?别人的大宝贝都有月饼为啥你没有。心疼靓仔三秒之余,我熬夜为广大靓仔准备了各式月饼,各位靓仔们准备好了,接下来开始接月饼了哈 🐒🐴

image.png

在线抽月饼

代码实现

月饼吃完了,我们来聊一聊这月饼实现方式了

Web Component

月饼抽奖骨架如下:指定了月饼种类、权重和颜色

    <lucky-wheel
        sectors='["蛋黄月饼 🥚🌕",
  "豆沙月饼 🥟😋",
  "五仁月饼 🌰🌕",
  "红豆沙月饼 🍓🌕",
  "提子月饼 🍇🌕",
  "绿茶月饼 🍵🌕",
  "奶黄月饼 🥛🌕",
  "火腿月饼 🍖🌕"]'
        probabilities="[15, 18, 20, 25, 8, 10, 20, 25]"
        start-angle="0"
        width="400"
        height="400"
        colors='["rgb(232, 98, 113)", "rgb(255, 255, 255)", "rgb(232, 98, 113)", "rgb(255, 255, 255)", "rgb(232, 98, 113)", "rgb(255, 255, 255)", "rgb(232, 98, 113)", "rgb(255, 255, 255)"]'
      >
   </lucky-wheel>

在内部还监听了它们的属性变化

  static get observedAttributes() {
    return ['sectors', 'probabilities', 'start-angle', 'colors']
  }
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      switch (name) {
        case 'sectors':
          this.sectors = JSON.parse(newValue)
          break
        case 'probabilities':
          this.probabilities = JSON.parse(newValue)
          break
        case 'start-angle':
          this.startAngle = Number.parseFloat(newValue)
          break
        case 'colors':
          this.colors = JSON.parse(newValue)
          break
      }
      this.render()
    }
  }

  connectedCallback() {
    this.render()
  }

基本元素绘制

我们可以看到抽奖界面主要由 外环、主表盘、中心盘和指针组成,接下来一一分析它们的实现

外环

  drawOuterRing(ctx, options = {}) {
    // 外环
    const {
      ringColor = 'rgba(238, 169, 74, 1)',
      numCircles = 18,
      circleOddColor = 'rgba(255, 252, 187, 1)',
      circleEvenColor = 'rgba(245, 212, 160, 1)',
    } = options
    const centerX = this.width / 2
    const centerY = this.height / 2
    ctx.save()
    ctx.beginPath()
    ctx.fillStyle = ringColor
    ctx.arc(centerY, centerY, centerX, 0, 2 * Math.PI, false)
    ctx.closePath()
    ctx.fill()
    // 环上圆点
    const angleIncrement = (2 * Math.PI) / numCircles
    const radius = (centerX - 10)
    for (let i = 0; i < numCircles; i++) {
      const angle = i * angleIncrement
      const circleX = centerX + radius * Math.cos(angle)
      const circleY = centerY + radius * Math.sin(angle)
      ctx.beginPath()
      ctx.arc(circleX, circleY, 5, 0, 2 * Math.PI)
      if (i % 2 === 0)
        ctx.fillStyle = circleOddColor

      else
        ctx.fillStyle = circleEvenColor

      ctx.fill()
    }
    ctx.restore()
  }

主表盘

主表盘由两部分组成:内环和一系列扇形块 扇形块根据权重进行绘制,同时绘制扇形片文字

  // 绘制扇形文字
  drawText(ctx, options = {}) {
    const {
      text = '',
      angle = '',
      textOffset = 85,
      color = '#eee',
      font = '20px Arial',
    } = options
    const centerX = this.width / 2
    const centerY = this.height / 2
    const textX = centerX + textOffset * Math.cos(angle)
    const textY = centerY + textOffset * Math.sin(angle)

    ctx.save()
    ctx.translate(textX, textY)
    ctx.rotate(angle)
    ctx.font = font
    ctx.fillStyle = color
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle' // 设置文字垂直居中对齐
    ctx.fillText(text, 0, 0)
    ctx.restore()
  }

  // 绘制每片扇形
  drawPerSector(ctx, chunks, options = {}) {
    const {
      ringWidth = 10,
      ringColor = 'rgba(0,0,0,0.1)',
    } = options
    const centerX = this.width / 2
    const centerY = this.height / 2
    const totalProbabilities = this.probabilities.reduce((a, b) => a + b, 0)
    // 初始化角度
    let accumulatedAngle = this.spinType === 'panel' ? this.startAngle : 0
    ctx.save()
    const radius = centerX - 20
    chunks.forEach((sector, index) => {
      const sectorAngle = ((360 * this.probabilities[index]) / totalProbabilities) * Math.PI / 180
      const endAngle = accumulatedAngle + sectorAngle
      ctx.beginPath()
      ctx.moveTo(centerX, centerY)
      ctx.arc(centerY, centerY, radius, accumulatedAngle, endAngle, false)
      ctx.closePath()
      ctx.fillStyle = this.colors[index] // 使用提供的颜色
      ctx.fill()
      if (index % 2 === 0) {
        this.drawText(ctx, {
          text: sector,
          angle: (accumulatedAngle + endAngle) / 2,
          color: 'rgb(255, 255, 255, 1)',
        })
      }
      else {
        this.drawText(ctx, {
          text: sector,
          angle: (accumulatedAngle + endAngle) / 2,
          color: 'rgb(233, 97, 113)',
        })
      }

      accumulatedAngle = endAngle
    })
    // 绘制内圈
    ctx.beginPath()
    ctx.arc(centerY, centerY, radius - ringWidth / 2, 0, 2 * Math.PI)
    ctx.lineWidth = ringWidth
    ctx.strokeStyle = ringColor
    ctx.stroke()
    ctx.restore()
  }

中心盘和指针

drawPointer(ctx, options = {}) {
    const {
      color = 'rgba(255,255,255, 1)',
      radius = 40,
      ringWidth = 8,
      ringColor = 'rgba(72, 72, 72, 1)',
      text = '奖',
      textColor = 'rgb(225, 101, 117)',
    } = options
    const centerX = this.width / 2
    const centerY = this.height / 2
    // 指针旋转角度
    const rotateAngle = this.spinType === 'pointer' ? this.startAngle : 0
    // 绘制外圆
    ctx.save()
    ctx.fillStyle = color
    ctx.beginPath()
    ctx.arc(centerX, centerY, radius, 0, 360 * Math.PI / 180)
    ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
    ctx.shadowBlur = 10
    ctx.shadowOffsetX = 1
    ctx.shadowOffsetY = 1
    ctx.fill()
    ctx.restore()

    // 绘制内圆
    ctx.beginPath()
    ctx.arc(centerX, centerY, radius - 10, 0, 2 * Math.PI)
    ctx.lineWidth = ringWidth
    ctx.strokeStyle = ringColor
    ctx.stroke()

    // 绘制文字
    ctx.save()
    ctx.translate(centerX, centerY)
    ctx.rotate(rotateAngle)
    ctx.font = '28px Arial'
    ctx.fillStyle = textColor
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(text, 0, 0)
    ctx.restore()

    // 绘制指针箭头
    const arrowX = centerX + (radius + 12) * Math.cos(rotateAngle)
    const arrowY = centerY + (radius + 12) * Math.sin(rotateAngle)
    ctx.beginPath()
    ctx.moveTo(arrowX, arrowY)
    ctx.arc(arrowX, arrowY, 18, rotateAngle - 140 * (Math.PI / 180), rotateAngle - 220 * (Math.PI / 180), true)
    ctx.fillStyle = color
    ctx.fill()
  }

添加交互

添加点击事件,触发开始抽奖,这里实现了几种常用的动画缓动函数,可以根据配置切换

  this.addEventListener('click', this.spin)
  
  spin(ctx, options = {}) {
    if (this.isSpinning)
      return

    this.isSpinning = true
    function getRandomInt(min, max) {
      return Math.floor(Math.random() * (max - min + 1)) + min
    }
    const {
      time = 0,
      begin = 0,
      type = 'ease-in-out',
      end = getRandomInt(4000 * Math.PI / 180, 40000 * Math.PI / 180),
      duration = 3000,
      callback = (value) => {
        this.startAngle = value
        this.render()
      },
    } = options
    this.play({
      time,
      begin,
      end,
      duration,
      type,
      callback,
    })
  }

  // 启动
  play(options) {
    return new Promise((resolve) => {
      let { time, begin, end, duration, type, callback } = options
      const durNums = Math.ceil(duration / 16.7)
      if (!window.requestAnimationFrame) {
        window.requestAnimationFrame = function (fn) {
          setTimeout(fn, 16.7)
        }
      }
      const step = () => {
        const value = getAnimationByType(type)(time, begin, end, durNums)
        callback(value)
        time++
        if (time <= durNums) {
          window.requestAnimationFrame(step)
        }
        else {
          switch (this.spinType) {
            case 'pointer':
              this.dispatchEvent(new CustomEvent('spinComplete', { detail: this.getPointerSelectedValue() }))
              break
            case 'panel':
              this.dispatchEvent(new CustomEvent('spinComplete', { detail: this.getPanelSelectedValue() }))
              break
          }
          this.isSpinning = false
          resolve()
        }
      }
      step()
    })
  }

抽奖结果

使用转动的总角度,取余得到 0~360 区间的角度,再计算每块扇形块的角度区间,转动角度落在哪个区间内,则是最终抽奖结果

  getPanelSelectedValue() {
    const totalProbabilities = this.probabilities.reduce((a, b) => a + b, 0)
    let turnAngle = (this.startAngle / (Math.PI / 180)) % 360
    let accumulatedAngle = 0
    if (turnAngle < 270)
      turnAngle = 270 - turnAngle
    else
      turnAngle = (360 - turnAngle) + 270

    for (let i = 0; i < this.probabilities.length; i++) {
      const sectorAngle = (360 * this.probabilities[i]) / totalProbabilities
      const endAngle = (accumulatedAngle + sectorAngle)
      if (accumulatedAngle <= turnAngle && endAngle >= turnAngle)
        return this.sectors[i]
      accumulatedAngle = endAngle
    }
    return this.sectors[0]
  }

后记

靓仔们,月饼吃完了,再看看我的其他系列吧~