最近在工作中遇到了小程序抽奖的一些页面制作,以前制作网页抽奖的时候,在jQuery插件库谁便找一个Demo就可以满足我们的需求了,自己动手写的话不仅会拖慢项目进度,而且写出来的也未必有别人的那么流畅自然。而在小程序中我们就没有这么幸福了(ಥ﹏ಥ),并没有丰富的插件库供给我们使用,所以该写的还得自己动手写。好了,下面是常见的两种抽奖方式:九宫格和转盘,在实际项目中用的还不错,分享给大家。
效果:
思路:
首先是结构上,用弹性布局就可以了,每一个奖品的宽度设置成3分之1,超出3个后让它换行;然后需要定义一个数组用来存放界面的奖品, 因为第五个内容是按钮,所以需要设置一个类型字段用来区分奖品和按钮。又因为需要一个旋转动画的效果,所以需要在奖品数组中设置一个是否已选中的状态字段。
接着是逻辑上,因为旋转的轨迹是顺时针的,所以得出轨迹的执行顺序数组为:[0, 1, 2, 5, 8, 7, 6, 3],接着我们只需要循环这个数组,在里面设置一个定时器,让它能够在n秒后把第n个奖品的状态改为已选中,这样经过一个循环后,就可以实现动画旋转一圈的视觉效果了;可以旋转一圈后我们可以把这段代码先封装起来,接着我们只需要去循环这段代码,并且再套一个计时器,这样就可以实现旋转多圈的效果了。
想法是完美的,但是实际项目中还存在很多的因素,比如说旋转每一圈中的衔接时间,调教旋转中的速度,让动画在最后一圈停留在对应的奖品上,这都是开发时要攻破的难关,具体还得认真观看下面代码。
wxml:
<view class="body">
<view class="box">
<block wx:for="{{prize_arr}}" wx:key="id">
<view wx:if="{{item.type=='btn'}}" bindtap="clickPrize" class="prize btn {{isTurnOver?'':'grayscale'}}">
{{item.name}}
view>
<view wx:else class="prize {{item.isSelected?'selected':''}}">{{item.name}}view>
block>
view>
view>
wxss:
.body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
}
.box {
display: flex;
flex-wrap: wrap;
width: 600rpx;
}
.box .prize {
width: 200rpx;
height: 200rpx;
line-height: 200rpx;
text-align: center;
font-size: 40rpx;
font-weight: 700;
color: white;
background-color: orange;
border: 2px solid snow;
box-sizing: border-box;
}
.box .btn {
color: tomato;
background-color: gold;
}
.box .selected {
background-color: orangered;
}
.grayscale {
filter: grayscale(50%);
}
js:
Page({
/**
* 页面的初始数据
*/
data: {
prize_arr: [
{id: '1',type: 'prize',name: '奖品1',isSelected: false},
{id: '2',type: 'prize',name: '奖品2',isSelected: false},
{id: '3',type: 'prize',name: '奖品3',isSelected: false},
{id: '4',type: 'prize',name: '奖品8',isSelected: false},
{id: '5',type: 'btn',name: '按钮',isSelected: false},
{id: '6',type: 'prize',name: '奖品4',isSelected: false},
{id: '7',type: 'prize',name: '奖品7',isSelected: false},
{id: '8',type: 'prize',name: '奖品6',isSelected: false},
{id: '9',type: 'prize',name: '奖品5',isSelected: false},
],
// 抽奖状态,是否转完了
isTurnOver: true
},
// 点击抽奖
clickPrize() {
// 如果不在抽奖状态中,则执行抽奖旋转动画
if (this.data.isTurnOver) {
// 把抽奖状态改为未完成
this.setData({
isTurnOver: false
})
// 这里开始假设已经调取后端接口拿到抽奖后返回的ID
let prize_id = 7;
// 随机奖品效果
// const rand = (m, n) => {
// return Math.ceil(Math.random() * (n - m + 1) + m - 1)
// }
// let prize_id = rand(1, 8)
// 调用抽奖方法
this.lottery(prize_id);
} else {
wx.showToast({
title: '请勿重复点击',
icon: 'none'
})
}
},
// 抽奖旋转动画方法
lottery(prize_id) {
console.log('中奖ID:' + prize_id)
/*
* 数组的长度就是最多所转的圈数,最后一圈会转到中奖后的位置
* 数组里面的数字表示从一个奖品跳到另一个奖品所需要的时间
* 数字越小速度越快
* 想要修改圈数和速度的,更改数组个数和大小即可
*/
let num_interval_arr = [90, 80, 70, 60, 50, 50, 50, 100, 150, 250];
// 旋转的总次数
let sum_rotate = num_interval_arr.length;
// 每一圈所需要的时间
let interval = 0;
num_interval_arr.forEach((delay, index) => {
setTimeout(() => {
this.rotateCircle(delay, index + 1, sum_rotate, prize_id);
}, interval)
//因为每一圈转完所用的时间是不一样的,所以要做一个叠加操作
interval += delay * 8;
})
},
/*
* 封装旋转一圈的动画函数,最后一圈可能不满一圈
* delay:表示一个奖品跳到另一个奖品所需要的时间
* index:表示执行到第几圈
* sum_rotate:表示旋转的总圈数
* prize_id:中奖后的id号
*/
rotateCircle(delay, index, sum_rotate, prize_id) {
// console.log(index)
let _this = this;
/*
* 页面中奖项的实际数组下标
* 0 1 2
* 3 5
* 6 7 8
* 所以得出转圈的执行顺序数组为 ↓
*/
let order_arr = [0, 1, 2, 5, 8, 7, 6, 3];
// 页面奖品总数组
let prize_arr = this.data.prize_arr;
// 如果转到最后以前,把数组截取到奖品项的位置
if (index == sum_rotate) {
order_arr.splice(prize_id)
}
for (let i = 0; i < order_arr.length; i++) {
setTimeout(() => {
// 清理掉选中的状态
prize_arr.forEach(e => {
e.isSelected = false
})
// 执行到第几个就改变它的选中状态
prize_arr[order_arr[i]].isSelected = true;
// 更新状态
_this.setData({
prize_arr: prize_arr
})
// 如果转到最后一圈且转完了,把抽奖状态改为已经转完了
if (index == sum_rotate && i == order_arr.length - 1) {
_this.setData({
isTurnOver: true
})
}
}, delay * i)
}
}
})
衔接版js: 非第一次转时,会把上一次未跳完的格子转完,逻辑就是点击抽奖时做个标记来判断是否需要把没跳完的格子跳完。
Page({
/**
* 页面的初始数据
*/
data: {
prize_arr: [
{id: '1',type: 'prize',name: '奖品1',isSelected: false},
{id: '2',type: 'prize',name: '奖品2',isSelected: false},
{id: '3',type: 'prize',name: '奖品3',isSelected: false},
{id: '4',type: 'prize',name: '奖品8',isSelected: false},
{id: '5',type: 'btn',name: '按钮',isSelected: false},
{id: '6',type: 'prize',name: '奖品4',isSelected: false},
{id: '7',type: 'prize',name: '奖品7',isSelected: false},
{id: '8',type: 'prize',name: '奖品6',isSelected: false},
{id: '9',type: 'prize',name: '奖品5',isSelected: false},
],
/*
* 数组的长度就是最多所转的圈数,最后一圈会转到中奖后的位置
* 数组里面的数字表示从一个奖品跳到另一个奖品所需要的时间
* 数字越小速度越快
* 想要修改圈数和速度的,更改数组个数和大小即可
*/
num_interval_arr: [90, 80, 70, 60, 50, 50, 50, 100, 150, 250],
// num_interval_arr: [90, 80],
/*
* 页面中奖项的实际数组下标
* 0 1 2
* 3 5
* 6 7 8
* 所以得出转圈的执行顺序数组为 ↓
*/
order_arr: [0, 1, 2, 5, 8, 7, 6, 3],
// 抽奖状态,是否转完了
isTurnOver: true,
// 是否需要复原,把没转完的圈转完
isRecover: false,
// 记录上一次抽奖后的奖品id
prize_id_last: ''
},
// 点击抽奖
clickPrize() {
// 如果不在抽奖状态中,则执行抽奖旋转动画
if (this.data.isTurnOver) {
// 把抽奖状态改为未完成
this.setData({
isTurnOver: false
})
// 这里开始假设已经调取后端接口拿到抽奖后返回的ID
let prize_id = 7;
// 随机奖品效果
// const rand = (m, n) => {
// return Math.ceil(Math.random() * (n - m + 1) + m - 1)
// }
// let prize_id = rand(1, 8)
// 调用抽奖方法
this.lottery(prize_id);
} else {
wx.showToast({
title: '请勿重复点击',
icon: 'none'
})
}
},
// 抽奖旋转动画方法
async lottery(prize_id) {
console.log('中奖ID:' + prize_id)
// 如果不是第一次抽奖,需要等待上一圈没跑完的次数跑完再执行
this.recover().then(() => {
let num_interval_arr = this.data.num_interval_arr;
let order_arr = this.data.order_arr;
// 旋转的总次数
let sum_rotate = num_interval_arr.length;
// 每一圈所需要的时间
let interval = 0;
num_interval_arr.forEach((delay, index) => {
setTimeout(() => {
this.rotateCircle(delay, index + 1, sum_rotate, prize_id, order_arr);
}, interval)
//因为每一圈转完所用的时间是不一样的,所以要做一个叠加操作
interval += delay * 8;
})
})
},
/*
* 封装旋转一圈的动画函数,最后一圈可能不满一圈
* delay:表示一个奖品跳到另一个奖品所需要的时间
* index:表示执行到第几圈
* sum_rotate:表示旋转的总圈数
* prize_id:中奖后的id号
* order_arr_pre:表示旋转这一圈的执行顺序
*/
rotateCircle(delay, index, sum_rotate, prize_id, order_arr_pre) {
// console.log(index)
let _this = this;
// 页面奖品总数组
let prize_arr = this.data.prize_arr;
// 执行顺序数组
let order_arr = []
// 如果转到最后以前,把数组截取到奖品项的位置
if (index == sum_rotate) {
order_arr = order_arr_pre.slice(0, prize_id)
} else {
order_arr = order_arr_pre;
}
for (let i = 0; i < order_arr.length; i++) {
setTimeout(() => {
// 清理掉选中的转态
prize_arr.forEach(e => {
e.isSelected = false
})
// 执行到第几个就改变它的选中状态
prize_arr[order_arr[i]].isSelected = true;
// 更新状态
_this.setData({
prize_arr: prize_arr
})
// 如果转到最后一圈且转完了,并且是非重置圈,把抽奖状态改为已经转完了
if (index === sum_rotate && i === order_arr.length - 1 && !this.data.isRecover) {
_this.setData({
isTurnOver: true,
isRecover: true,
prize_id_last: prize_id
})
}
}, delay * i)
}
},
// 复原,把上一次抽奖没跑完的次数跑完
async recover() {
if (this.data.isRecover) { // 判断是否需要重置操作
let delay = this.data.num_interval_arr[0]; // 为了衔接流程,使用第一圈的转速
// console.log(delay)
let order_arr = this.data.order_arr;
// console.log(order_arr)
let prize_id_last = this.data.prize_id_last; // 上一次抽奖的id
// console.log(prize_id_last)
order_arr = order_arr.slice(prize_id_last); // 截取未跑完的格子数组
// console.log(order_arr)
return await new Promise(resolve => { // 确保跑完后才去执行新的抽奖
this.rotateCircle(delay, 1, 1, 8, order_arr); // 第一圈的速度,最多只有一圈,旋转一圈,跑到最后一个奖品为止,未跑完的数组
setTimeout(() => { // 确保跑完后才告诉程序不用重置复原了
this.setData({
isRecover: false,
})
resolve() // 告诉程序Promise执行完了
}, delay * order_arr.length)
})
}
}
})
效果:
思路:
首先是结构上,我们从设计那拿到psd后,把转盘背景和指针按钮分离成两张图片就好,接着用弹性布局或悬浮布局定一下位置就好。
接着是逻辑上,我的第一想法使用css3的animation+transform:rotate()来制作,但是想了一下要动态设置旋转的角度,小程序也不好操作dom,所以直接使用官方提供的Animation API来制作了。需要注意一下我这转盘是有6个奖项,所以一个奖项的度数为:60,根据自己的项目,换算一下就好了;另外需要注意的是小程序是否要执行动画是看当前状态和要执行的实例数据间是否存在差值,换句话说就是如果我第一次抽到3等奖,我第二次又是抽中了3等奖,旋转的度数没有变,那么小程序会判断为当前不存在差值,所以会偷懒,直接不转了,具体解决方案看代码。
wxml:
<view class="body">
<view class="box">
<image class="bg" src="{{bg}}" animation="{{animationRotate}}">image>
<image bindtap="lottery" class="arrows {{isTurnOver?'':'grayscale'}}" src="{{arrows}}">image>
view>
view>
wxss:
.body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
}
.box {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 600rpx;
height: 600rpx;
}
.box .bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.box .arrows {
position: relative;
top: -28rpx;
width: 200rpx;
height: 249rpx;
}
.grayscale {
filter: grayscale(70%);
}
js:
Page({
/**
* 页面的初始数据
*/
data: {
// 转盘奖品背景
bg: "http://r.photo.store.qq.com/psc?/V14ZaBeY40XWC8/45NBuzDIW489QBoVep5mcT0Lq.gBxwEb6.hf44ibdoBF*Yne9.pzFu6WW1t*k2C7iFgwK3jc0jMvFefvW0IfraslW4FNcfHPYyIdFLecyJ0!/r",
// 转盘箭头抽奖按钮
arrows: "http://r.photo.store.qq.com/psc?/V14ZaBeY40XWC8/45NBuzDIW489QBoVep5mcT0Lq.gBxwEb6.hf44ibdoA2KiCwrHNJS*10VP4yg3bnkt2TA7zugdvTHvyHaWr2uplgBObbdubFayYw6e*PbW0!/r",
// 抽奖状态,是否转完了
isTurnOver: true
},
// 点击抽奖按钮
lottery() {
// 如果不在抽奖状态中,则执行抽奖旋转动画
if (this.data.isTurnOver) {
// 把抽奖状态改为未完成
this.setData({
isTurnOver: false
})
// 这里开始假设已经调取后端接口拿到抽奖后返回的ID
let prize_id = 3;
// 随机奖品效果
// const rand = (m, n) => {
// return Math.ceil(Math.random() * (n - m + 1) + m - 1)
// }
// let prize_id = rand(1, 6)
// 调用旋转动画方法
this.rotate(prize_id)
} else {
wx.showToast({
title: '请勿重复点击',
icon: 'none'
})
}
},
// 旋转动画方法
rotate(prize_id) {
// 执行完动画所需要的时间
let _duration = 10000;
let animationRotate = wx.createAnimation({
duration: _duration,
timingFunction: 'ease', //动画以低速开始,然后加快,在结束前变慢
})
// 解决二次点击抽奖后不再旋转的问题
animationRotate.rotate(0).step({
duration: 1
})
/*
* 旋转的角度
* 这转盘有6个奖项,所以一个奖项的度数为:360除以6等于60、
* 要转完一圈 → 60 * 6
* 为了让动画看起来舒服我设置了20圈 → 60 * 6 * 20
* 又因为转盘是顺时针旋转的,默认指定奖品1
* 所以需要减去 → 60 * (prize_id - 1) 方可在最后一圈转到对应的位置
* 可以根据自己的设计稿奖品的个数进行调整
* */
let angle = (60 * 6 * 20) - 60 * (prize_id - 1);
animationRotate.rotate(angle).step()
this.setData({
animationRotate: animationRotate.export()
})
// 设置倒计时,保证最后一圈执行完了,才更改状态
setTimeout(() => {
this.setData({
isTurnOver: true
})
}, _duration)
}
})
衔接版js: 会在原来的角度上继续旋转,逻辑就是让角度无线放大,比如第一次抽奖圈转了20圈,那么第二次点击就会转到40圈的位置,实际也是转了20圈上下,因为程序偷懒,跑过的20圈并不会再跑了。
Page({
/**
* 页面的初始数据
*/
data: {
// 转盘奖品背景
bg: "http://r.photo.store.qq.com/psc?/V14ZaBeY40XWC8/45NBuzDIW489QBoVep5mcT0Lq.gBxwEb6.hf44ibdoBF*Yne9.pzFu6WW1t*k2C7iFgwK3jc0jMvFefvW0IfraslW4FNcfHPYyIdFLecyJ0!/r",
// 转盘箭头抽奖按钮
arrows: "http://r.photo.store.qq.com/psc?/V14ZaBeY40XWC8/45NBuzDIW489QBoVep5mcT0Lq.gBxwEb6.hf44ibdoA2KiCwrHNJS*10VP4yg3bnkt2TA7zugdvTHvyHaWr2uplgBObbdubFayYw6e*PbW0!/r",
// 抽奖状态,是否转完了
isTurnOver: true,
// 转的总圈数,最后一圈可能不满
num_total: 20
},
// 点击抽奖按钮
lottery() {
// 如果不在抽奖状态中,则执行抽奖旋转动画
if (this.data.isTurnOver) {
// 把抽奖状态改为未完成
this.setData({
isTurnOver: false
})
// 这里开始假设已经调取后端接口拿到抽奖后返回的ID
let prize_id = 3;
// 随机奖品效果
// const rand = (m, n) => {
// return Math.ceil(Math.random() * (n - m + 1) + m - 1)
// }
// let prize_id = rand(1, 6)
// 调用旋转动画方法
this.rotate(prize_id)
} else {
wx.showToast({
title: '请勿重复点击',
icon: 'none'
})
}
},
// 旋转动画方法
rotate(prize_id) {
// 执行完动画所需要的时间
let _duration = 10000;
let animationRotate = wx.createAnimation({
duration: _duration,
timingFunction: 'ease', //动画以低速开始,然后加快,在结束前变慢
})
/*
* 旋转的角度
* 这转盘有6个奖项,所以一个奖项的度数为:360除以6等于60、
* 要转完一圈 → 60 * 6
* 为了让动画看起来舒服我设置了20圈 → 60 * 6 * 20
* 又因为需要连贯抽取非第一次,所以二次抽奖时会在原来的圈数上自加,
* 也就成了60 * 6 * num_total,num_total每转完一次都会叠加上自身
* 又因为转盘是顺时针旋转的,默认指定奖品1
* 所以需要减去 → 60 * (prize_id - 1) 方可在最后一圈转到对应的位置
* 可以根据自己的设计稿奖品的个数进行调整
* */
let num_total = this.data.num_total;
let angle = (60 * 6 * num_total) - 60 * (prize_id - 1);
animationRotate.rotate(angle).step()
this.setData({
animationRotate: animationRotate.export()
})
// 设置倒计时,保证最后一圈执行完了,才更改状态
setTimeout(() => {
this.setData({
isTurnOver: true,
num_total: num_total + num_total
})
}, _duration)
}
})
素材:
概率: 虽然概率这块是放在后端处理,但是它的大概原理是怎么样的呢?接下来我会以我司的抽奖后台方式来和大家探讨。从下面的图中可以看到,每一项奖品都会有一个ID、名称,另外除了奖品数量外还有奖品概率,这就意味着奖品的概率并不一定是按照总数占比来的,并且可以看到红字,一般总概率是要为100(即100%,我猜这样做的目的是为了防止有小数点的情况造成比例不精准)。
▲抽奖方式可能会有出入的地方:
1.我司的抽奖方式是这样的,它并不一定是只抽一下的,会按概率的级别依次抽取,每个奖品都会抽一次,比如说先以奖品一的抽奖概率去抽奖品一,如果没有抽中,那么就会去用奖品二的概率抽取奖品二,如果奖品二没有抽中继续往后,如果所有奖品抽过一次都没中,那返回未中提示;
2.另外,比如我抽到了一等奖,但是一等奖被抽光了,那么此时它会直接向下取去拿奖品二,如果奖品二也没有,会一直往后取。大概意思是我抽中一等奖了,抽完了没有货,那么就有资格直接拿后面级别的奖品,而非重新去抽取。
下面是我通过制作一些抽奖活动后,自个理解后写的抽奖概率代码,当然并不是说后端的代码就是这样,毕竟后端不止一门语言,而且还会涉及到数据库操作,我这代码仅供大家参考参考,提升提升思维而已。(未抽中和谢谢惠顾的意思是一样的)
/*
* 奖品数组
* num: 该奖品剩余数量
* probability: 该奖品抽中概率
*/
let prizeArr = [
{id: '0',name: '一等奖',num: 1,probability: 10},
{id: '1',name: '二等奖',num: 2,probability: 20},
{id: '2',name: '三等奖',num: 3,probability: 30},
{id: '3',name: '谢谢惠顾',num: 99999,probability: 40}
];
// 定义数组对象排序函数
function compare(property) {
return function(a, b) {
let value1 = a[property];
let value2 = b[property];
return value1 - value2; //降序为value2 - value1
}
}
// 根据奖品的概率重新排序,因为后续如果抽中靠前的奖品,并且该奖抽完了,会直接向下取奖品
prizeArr.sort(compare('probability'))
// 得到一个两数之间的随机数
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
// 点击抽奖
function clickLottery() {
let sum = prizeArr.reduce((prev, cur) => prev + cur.num, 0); // 剩余奖品的总数
// console.log(sum)
let totalProbability = prizeArr.reduce((prev, cur) => prev + cur.probability, 0); // 全部奖品的总概率
// console.log(totalProbability)
if (sum <= 0) {
console.log("奖品已经抽完了!")
return
}
try {
prizeArr.forEach((e, index) => {
let probability = e.probability; // 当前奖品的概率
// console.log(probability)
let random_num = getRandomArbitrary(0, totalProbability); // 0到总概率的随机数
// console.log(random_num)
if (random_num <= probability) { // 如果随机数小于等于抽奖概率,说明抽中了该项奖品
// console.log(e.num)
if (e.num > 0) { // 如果当前奖品还有余量,返回改奖项
e.num -= 1; // 相应的奖品数减一
throw Error(e.name)
} else { // 否则无需再抽,直接向下取奖品
if (index + 1 < prizeArr.length) { // 如果抽中的不是最后一个奖品,数组未越界
for (let i = index + 1; i <= prizeArr.length; i++) {
if (prizeArr[i].num > 0) { // 如果后面的奖品还有余量,返回该奖项
prizeArr[i].num -= 1; // 相应的奖品数减一
throw Error(prizeArr[i].name)
}
}
}
}
}
})
} catch (e) {
console.log(e.message)
return
}
console.log("很遗憾未抽中!")
}
// 触发抽奖
clickLottery();
// console.log(prizeArr)