前言

这是之前在玩游戏的时候,发现平台为了这个游戏做了一个九宫格的抽奖,虽然之前业务上没有这方面的需求,但本人对这个很感兴趣,在完成之后,现在有时间因此整理一下供有需求的同学参考;

简介

代码已上传JQ的一个插件库,下载地址是:http://www.jq22.com/yanshi22780;(不需要jq币)

这是一个基于H5的小插件,使用的是jQuery,是移动端的,兼容性方面没有考虑IE,因为demo版本,因此没有使用到图片,纯文字展示奖品(PS:当时因为时间关系,代码也没有做一些优化,因此有些部分写的还略显粗糙不够优雅,请多多见谅…);

演示:

jquery 抽奖数字滚动 停止 jquery抽奖插件_jquery 抽奖数字滚动 停止

正文开始

流程分析

最终效果是需要达到:点击“开始”后,从左上角第一个方块开始,围绕开始按钮有一个转动的动画,该动画从开始到结束有一个加速->匀速->减速的过程,最终动画停止在某个奖品上;

因此,整个过程大致的步骤有:

  1. 根据传入的参数,创建9个div,并排列整齐;
  2. 点击中间的“开始”按钮,启动加速动画,同时,根据参数筛选出最终奖品;
  3. 启动加速动画,在某个时间段内不断加速转动;
  4. 启动匀速动画,在某个时间段内保持匀速转动;
  5. 启动减速动画,在某个时间段内不断减速,直至停到到最终奖品上;

创建插件

在正式开始写抽奖之前,虽然写的是小demo,但是当然还是要正规一点,功能什么的还是齐全的,因此在当下当然都是插件化组件化的,因此这个小demo还是尽量做成插件,jq的插件写法有很多种,我个人一直比较喜欢下面这种写法:

//用一个立即执行函数将代码包裹起来,防止变量污染
(function(root,func,plugin){
    func(jQuery,plugin)
})(this,function(jQuery,plugin){
  //给$的fn添加一个名为prizeDraw的函数
	$.fn[plugin] = function(params) {
  
  }
},'prizeDraw')

PS:关于立即执行函数,闭包等知识点可以查看我的另外一篇文章《JavaScript》JavaScript进阶知识点(一)

创建布局

在这个阶段,最终要实现的目标是:接收一个长度为8的奖品数组为参数,加上“开始”按钮,一共9个方块,创建出3*3的布局,按钮在正中间,奖品围绕按钮成一圈;

因此这个阶段分为两个小节:

  • 第一个小节:接收数组后,生成对应的奖品div,这些div加入DOM;
  • 第二个小节:对加入DOM这些奖品div以及“开始”按钮进行位置排列,最终达到图上的显示效果

创建DIV

(function(root,func,plugin){
    func(jQuery,plugin)
})(this,function(jQuery,plugin){
    const defaultName = '谢谢惠顾';
    //奖品列表上限数量8
    const maxListNumber = 8;
    const puginID = 'oliverPrizeDraw';
    const row = 3;
    const column = 3;

    let flg=false;
    let _this_;
    $.fn[plugin] = function(params) {
        let _this = this;
        if(!isObj(params)) throwError('传入的参数必须是一个对象');
      	
      	//此时的createDOMList仅仅是一个数组,尚未插入DOM树中
        let createDOMList = createDomListArr(params.prizeList);
        
        //在createDOM()函数中创建DOM,并返回了中间“开始”按钮的ID,这样方便给按钮添加点击事件
        let btn = createDOM(createDOMList,_this);

    }
		//根据参数,按顺序创建了9个div,保存在了数组中返回
    function createDomListArr(arr){
        let newArr = [];
        arr.forEach((el,index) => {
            newArr.push(`<div data-id='${el.id}' index='${index}'>${el.name}</div>`)
        })
        return newArr;
    }
    //创建DOM
    function createDOM(list,dom){
        //创建了一个div,作为外框的父级元素
        let divContainer = `<div id='${puginID+'Container'}'></div>`;
      	//将外框加入页面上
        dom.append(divContainer);
      
      	//创建一个div用来存放8个抽奖元素
        let div = `<div id='${puginID+'Box'}'></div>`;
      	//将用来存放抽奖元素的div加入外框下
        $('#'+puginID+'Container').append(div);
      	
      	//获得到上面刚刚加入到页面上用来存放抽奖元素的box
        let box = $('#'+puginID+'Box');
      	//将上面生成的8个抽奖元素统统放进去,此时里面的抽奖元素还没有最终排列成需要的形态
        box.append(list);
      	
      	//获得8个抽奖元素
        let children = box.children();
      	
        //对每个抽奖元素的大小进行设定,每个方块的大小都设定成父元素的三分之一
      	//里面的-4是因为需要做间隔
        children.css({
            'width':box.width()/row-4+'px',
            'height':box.height()/column-4+'px',
            'line-height':box.height()/column-4+'px',
        })
    }

    //是否是个对象
    function isObj(obj){
        return Object.prototype.toString.call(obj) === '[object Object]';
    }
},'prizeDraw')

当执行到这里的时候,已经创建好了8个div,并且每个div的大小都已经设置成外框的三分之一大小了(其他具体的样式,比如间隔,背景色等等都是通过css设置的);

开始按钮和排列位置

当时的基本思路是,一个父级元素div作为外框,下面有两部分:

  • 第一部分是中间的开始按钮;
  • 第二部分是一个div,这个div下有按钮周围一圈的奖品;

这两部分一共9格,采用的都是绝对定位,只有绝对定位可以按照意愿随意摆放位置;

//创建DOM
    function createDOM(list,dom){
      	/*
        	第一小节代码放这里
        */
      	//-----------------分割线--------------//
      	
      	//对8个元素每个都执行一次位置排放
        children.each((index,el)=>{
          	//第0-1个,也就是第1、第2个方块
          	//离顶部的距离是0,每一个方块相对前面一个方块离左侧的距离是多一个方块的宽度
            if(index >= 0 && index < (row-1)){
                $(el).css({
                    'top':0,
                    'left':index*(box.width()/row)+'px'
                })
            }
          	//第3,第4个方块,离右侧的距离是0,离顶部是逐一多一个方块的距离
          	//
            else if(index>=row-1 && index<row+column-1){
                $(el).css({
                    'right':0,
                    'top':(index-2)*(box.height()/column)+'px'
                })
            }
          //第5,第6个方法,离底部的距离是0,离右侧的距离逐一多一个方块的距离
            else if(index>=row-1+column&&index<(2*row+column-2)){
                $(el).css({
                    'bottom':0,
                    'right':(index-(row+column-2))*(box.width()/row)+'px'
                })
            }
          //第7,第8个,离左侧的距离是0,距离顶部的距离是处于第二排,第三排
            else{
                $(el).css({
                    'top':(index-(2*(row+column)-6))*(box.height()/column)+'px',
                    'left':0
                })
            }
        })
				
      	//创建开始按钮
        let startBtn = `<span id='${puginID+'StartBtn'}' class='start-btn start-btn-able'>开始</span>`;
        //将按钮加入DOM
      	box.parent().append(startBtn);
      	给按钮设置样式,将其置于正中间
        $('#'+puginID+'StartBtn').css({
            'width':box.width()/row-4+'px',
            'height':box.height()/column-4+'px',
            'line-height':box.height()/column-4+'px',               
            'left':box.width()/row+'px',
            'top':box.height()/column+'px',
            // 'transform':'translate(-50%,-50%)'
        })
      	//返回按钮
        return {
            startBtn:puginID+'StartBtn',
            boxId:puginID+'Box'
        }
    }

筛选奖品和启动转动动画

在这个阶段,需要给开始按钮绑定点击事件,点击后可以启动转动动画,当然在启动之前,必须对奖品数组进行一系列检测,如果有错误进行简单的处理,最终返回一个我们需要的抽奖数组,这个最终的抽奖数组才上面插入DOM的div,也就是说,实际上对传入的参数进行检测这一步应该放在排列位置之前

有了最终的合法的抽奖数组,那么就可以根据需求,筛选最终奖品;

检测参数

$.fn[plugin] = function(params) {
        let _this = this;
        if(!isObj(params)) throwError('传入的参数必须是一个对象');
      	
   			//再创建之前,需要先对传入的参数做一次检测,如果参数错误,长度错误可以及时补齐或抛出异常
        params.prizeList = finalList(params.prizeList);
   
      	//此时的createDOMList仅仅是一个数组,尚未插入DOM树中
        let createDOMList = createDomListArr(params.prizeList);
        let btn = createDOM(createDOMList,_this);

    }
//检测参数
function finalList(arr){
      //存在且必须是数组
      if(!(arr && Array.isArray(arr))){
          let newArr = [];
          for(let i = 0 ; i < maxListNumber ; i++){
            	//数组每一项都有名字,id和概率
              newArr.push({
                  name:defaultName,
                  id:puginID + i,
                  percent:100 / maxListNumber
              });
          }
        	//返回数组
          return newArr;
      }
  		//概率这一项必须是数字
      arr.forEach((el)=>{
          if(!isNumber(el.percent)){
              throwError('奖品列表的percent的值必须是数字类型,当前ID为:'+el.id+' 的percent值不是数字');
          }
      })
      //奖品列表长度必须是8
      if(arr.length === maxListNumber){
        	//概率的总和必须是100,不能8个方块的概率加起来超出100了
          let percent = resultPro(arr);
          if(percent !== 100) throwError('奖品列表的概率和必须是100,当前是:'+percent);
          return arr;
      }
      else{
        	//假如传入的参数的长度超过了8,提示
          let length = maxListNumber - arr.length;
          length = length > 0 ? length : throwError('奖品列表的数量上限是:'+maxListNumber+',当前是:'+arr.length);
          //当前概率和
          let current = resultPro(arr);
          if(current>100) throwError('奖品列表的概率和必须小于100,当前是:'+current);

          for(let i = 0 ; i < length ; i++){
              arr.push({
                  name:defaultName,
                  id:puginID + i,
                  percent:(100 - current) / length
              })
          }
        	//返回打乱的数组,不能是输入的奖品顺序是什么就是什么
          return arr.sort(randomArr);
      }
  }
 //判断当前的奖品列表的概率总计
  function resultPro(arr){
      if(!Array.isArray(arr)) throwError('resultPro的参数必须是数组');

      let result = 0;
      arr.forEach((el) => {
          el.percent && isNumber(el.percent)? result = result + el.percent : '';
      })

      return result;
  }

//打乱数组
function randomArr(a,b){
    return Math.random()>.5 ? -1 : 1;
}
 //抛出异常
function throwError(val){
    throw new Error(val)
}

等检测完,此时返回的抽奖数组就是一个完整的奖品数组了,在第一步创建div的时候就可以按照这个顺序直接创建div并排列;

点击开始按钮

到这一阶段,就需要给开始按钮添加点击事件了,并且点击后开始按钮进入disable的状态,不然连续点击就会出现问题,并且,点击“开始”按钮后,会出现两种情况:

  • 存在指定奖品,也就是俗称的黑幕,不管谁抽,都是谢谢惠顾之类的;
  • 不存在指定奖品,那么就会按照设定的概率进行抽奖;
$.fn[plugin] = function(params) {
        let _this = this;
        if(!isObj(params)) throwError('传入的参数必须是一个对象');

        //生成抽奖数组
        params.prizeList = finalList(params.prizeList);

        let createDOMList = createDomListArr(params.prizeList);
        
        //创建DOM并返回按钮ID
        let btn = createDOM(createDOMList,_this);
        let boxChinldren = btn.boxId;
        
        $('#'+btn.startBtn).on('click',function(){
            _this_ = this;
          	//通过flg开关,判断是否可以点击
            if(!flg){
              	//添加不可点击时的样式;
                $(_this_).addClass('start-btn-disable').removeClass('start-btn-able');
                flg = true;

                //判断是否有指定奖品ID
              	//假如有指定奖品,那么概率抽奖就不会生效,假如没有,那么就进行概率抽奖
                if(params.finalPrizeID&&isString(params.finalPrizeID)){
										//判断一下设定的最终奖品id存不存在
                  	//不要设定了一个奖品id,结果奖品列表中没有,或者有2个及2个以上的奖品
                    let array = [];
                    params.prizeList.forEach(element => {
                        if(element.id === params.finalPrizeID){
                            array.push(element);
                        }
                    })
										//根据数组长度进行判断
                    switch(array.length){
                        //长度是0,那么就说明指定的id不存在,那么将进行概率抽奖
                        case 0:
                            console.log('指定的奖品ID在奖品列表中不存在,将按指定概率进行抽奖');
                        		//这个是抽奖函数,下一大节解释这个函数
                            targetPrize(params.prizeList,boxChinldren);
                            break
                        //长度是1,那么这个就是正常情况,这个奖品就是最终奖品
                        case 1:
                            let name = array[0].name?array[0].name:'默认名字';
                            console.log('指定奖品为:'+name);
                            targetPrize(array[0],boxChinldren);
                            break
                        //长度超出了1个,那么就是存在多个相同id的最终奖品,其实也可以进行概率抽奖
                        default:
                            throwError('指定的最终奖品ID在奖品列表中不唯一');
                            break;
                    }
                }
              	//没有指定奖品,那么就正常进行概率抽奖
                else{
                    console.log('无指定奖品,将按指定概率进行抽奖');
                    targetPrize(params.prizeList,boxChinldren);
                }
            }
        })
    }

抽奖函数

在上一大节中,通过targetPrize函数,进行抽奖,其中第一个参数:

  • 如果是一个对象,那么就代表直接传递过来了最终奖品,那么就不需要通过函数去计算哪个是最终奖品;
  • 如果是一个数组,那么代表使用者并没有设定最终奖品,需要按照概率进行随机抽取;

其中,如果是数组,大致上就是将数组中的每一项奖品的概率转成一个区间,比如:第一个奖品的概率是10,那么在100中,随机数0-9指的就是这个奖品,如果第二个奖品的该也是10,那么转换后10-19就是这个奖品,第三个奖品的概率是30,那么它所对应的区间就是20-59,以此类推;
这样就将奖品的概率平铺满了100,到这里的抽奖只需要随机数一个0-100的数字,这个数字在哪个区间,就代表抽中了哪个奖品;

//执行抽奖,传入的正常抽奖或者是指定奖品,dom列表
    function targetPrize(params,dom){
        //如果是对象,执行指定奖品,如果是数组,执行概率抽奖
        isObj(params)?
        pointPrize(params,dom):
        Array.isArray(params)?
        percentPrize(params,dom):
        throwError('targetPrize()参数错误');
    }
    
    //指定奖品
    function pointPrize(params,dom){
        //指定产品的位置
        let ax = targetIndex(params,dom)
        console.log(params);
        speedUp(dom,ax);
    }

    //概率抽奖
    function percentPrize(params,dom){
        //将概率转成数组区间
        let newArr = arrayPercent(params);
        //获得最终奖品
        let percent = objPercent(newArr);
        console.log(percent);

        //指定产品的位置
        let ax = targetIndex(percent,dom)
        speedUp(dom,ax);
    }
    //将概率转成区间数组
    function arrayPercent(arr){
        let sum = 0;
        let newArr=[];

        arr.forEach((el)=>{
            el.percent&&isNumber(el.percent)?
            newArr.push({
                name:el.name,
                id:el.id,
                percent:[sum,(sum+el.percent)===100?(sum=sum+el.percent):(sum=sum+el.percent)-1]
            }):'';
        })
        return newArr;
    }

    //选出一个随机数并返回指定区间的对象
    function objPercent(arr){
        let radomNum = Math.floor(Math.random() * 100);
        console.log(radomNum);
        for(let el of arr){
            if(radomNum >= el.percent[0] && radomNum <= el.percent[1]){
                return el;
            }
        }
    }

转动动画

转动动画是通过div的背景色的顺时针的顺序切换实现的,通过不断的顺时间变化给人一种转动的感觉,而启动动画就是开始每一个变化的间隔都较长,然后变化的间隔逐渐变短,造成加速的视觉过程;减速同理,也就是间隔变长导致了转速下降的视觉错觉;

这是一个选中div的函数,因为不管是加速,匀速,减速,都是需要选中div改变背景色,因此这个过程抽离了出来,方便在三个速度中调用

//选中dom对象
function runAnimate(dom){
    if(i<7){
        i++;
    }
    else{
        i=0;
    }
    $('#'+dom).children().removeClass('selected').eq(i).addClass('selected');
}

加速动画

//不管是概率抽奖还是指定奖品抽奖,最终都会执行到这个加速动画  
//设定时间
const normalTime = 50,maxTime=300;
//转动次数
const normal = 2400/normalTime;
let timer,i,t;
let v;
const a=-20;

 //执行动画,加速
function speedUp(dom,index){
  	//将dom传递进去,当然也可以直接将dom写进选中的函数里
    runAnimate(dom);
  	//如果间隔已经达到设定的值,那么就执行匀速动画函数
    if(v<=normalTime){
        v=normalTime;
        clearTimeout(timer);
        speedNormal(dom,index);
    }
  	//else里面则是间隔还没有达到设定的要求,还需要继续循环加速
    else{
        v = v+a;
        timer = setTimeout(()=>{
            speedUp(dom,index)
        }, v);
    }
}

匀速动画

//执行动画,匀速
function speedNormal(dom,index){
  	//选中div
    runAnimate(dom);
  	//执行一段时间的匀速动画,
    timer = setTimeout(() => {
        speedNormal(dom,index);
    }, v);
  
  	//判断执行匀速动画的时间有没有到达设定的值了
  	//转动的时间大于或等于设定的值,那么就代表可以开始执行减速动画了
    if(t >= normal){
        clearTimeout(timer);
        t=0;
        speedDown(dom,index);
    }
  	//还没到达设定的时间,继续执行匀速动画
    else{
        t++;
    }
}

减速动画

减速动画最为复杂,因为需要将最终的背景色变化停留在最终奖品上

//转成DOM,减速
function speedDown(dom,index){
  	//选中dom
    runAnimate(dom);
  	//判断当前的时间还够不够转一圈,如果够,那么转完一圈再判断
  	//当前的时间不够再转一圈了
    if(v>=maxTime+8*a){
        clearTimeout(timer)
        timer = setTimeout(()=>{
          	//判断i是不是最终的奖品
            if(i == index){
              	//停止循环
                clearTimeout(timer);
              	//可以按钮可以再次点击
                flg = false;
              	//最终奖品的背景色变化
                $(_this_).removeClass('start-btn-disable').addClass('start-btn-able');
            }
          	//不是最终奖品,继续执行减速动画
            else{
                speedDown(dom,index)
            }
        },v)
    }
  	//当前的总时间够一圈
    else{
        v = v-a;
        timer = setTimeout(() => {
            speedDown(dom,index);

        }, v);
    }
}

总结

在这个小demo中,个人认为的难点有以下几个:

  • 筛选最终奖品,其中需要对传入的参数有一个预处理,因为不能保证传入的参数是完全符合要求的,因此需要做一个判断,并返回一个最终需要的奖品数组;
  • 奖品数组转成概率区间,需要通过叠加将每个奖品的概率转成到0-100之间的某一段,这样可以通过随机数判断处于哪一段奖品内;
  • 减速动画,减速动画需要判断剩余的时间,够不够再转一圈,直至不够转一圈的时候需要判断当前的方块是不是最终奖品的那个方块,如果是,那么就需要停止继续转动;

如果这个demo有什么错误或者其他疑问欢迎留言,本菜鸡随时等候~