这个功能可以分几步实现:
1. 界面设计:
转盘区域: 使用 canvas 绘制转盘,可配置扇形数量、颜色、文字等。
按钮: "开始/停止" 按钮控制转盘转动。
编辑按钮: 点击弹出弹窗,编辑转盘项目。
中奖弹窗: 显示中奖结果。
2. 数据结构:
使用数组存储转盘项目数据,例如:
const prizeList = [{ name: '一等奖', color: '#FFD700' },{ name: '二等奖', color: '#C0C0C0' },{ name: '三等奖', color: '#CD7F32' },// ... 其他奖项
];
3. 功能实现:
绘制转盘:
根据 prizeList 数据计算每个扇形的角度。
使用 canvas API 绘制扇形、文字、边框等。
drawPrizeWheel() {// 创建离屏 2D canvas 实例,创建canvas元素const canvas = wx.createOffscreenCanvas({type: '2d', width: 300, height: 300});// 获取 context。注意这里必须要与创建时的 type 一致const ctx = canvas.getContext('2d');const centerX = 150;const centerY = 150;const radius = 130;const prizeCount = this.data.prizeList.length;const anglePerItem = 360 / prizeCount;// 记录当前角度,初始为 0/90 度let currentAngle = 90; for (let i = 0; i < prizeCount; i++) {ctx.beginPath();ctx.moveTo(centerX, centerY);ctx.arc(centerX,centerY,radius,(i * anglePerItem * Math.PI) / 180,((i + 1) * anglePerItem * Math.PI) / 180,);ctx.closePath();ctx.fillStyle = this.data.prizeList[i].color;ctx.fill();// --- 绘制文字 ---ctx.save(); // 保存当前画布状态ctx.font = '12px sans-serif';ctx.fillStyle = '#fff';ctx.textAlign = 'center';ctx.textBaseline = 'middle';const textAngle = (i * anglePerItem + anglePerItem / 2) * (Math.PI) / 180;const textX = centerX + radius * 0.7 * Math.cos(textAngle);const textY = centerY + radius * 0.7 * Math.sin(textAngle);ctx.fillText(this.data.prizeList[i].name, textX, textY);ctx.restore(); // 恢复之前的画布状态// --- 文字绘制结束 ---// 计算当前奖项区域的结束角度let endAngle = currentAngle + anglePerItem;if(endAngle > 360){endAngle %= 360;}// 计算当前奖项区域的起始角度和结束角度// 将角度信息存储到 prizeList 数组中this.data.prizeList[i].startAngle = currentAngle;this.data.prizeList[i].endAngle = endAngle;// 更新 currentAngle,准备绘制下一个区域currentAngle = endAngle; }// 将canvas转换为DataURL格式的图片var dataURL = canvas.toDataURL('image/png', 1);this.setData({wheelImg: dataURL});},
代码解释:
初始化 currentAngle: 在循环开始之前,将 currentAngle 初始化为 90,表示从 90 度开始绘制。因为canvas 绘制圆弧的起始角度是水平向右的 x 轴正方向,而不是竖直向上的 y 轴正方向。所以这里实际起始角度是从90度开始。
计算结束角度: 在每次循环中,根据 currentAngle 和 anglePerItem 计算当前奖项区域的结束角度 endAngle。
绘制扇形: 使用 currentAngle 和 endAngle 绘制当前奖项区域的扇形。最后把canvas转换为base64图片地址。
记录角度信息: 将 currentAngle 和 endAngle 存储到 prizeList 数组中,方便后续判断中奖区域。
更新 currentAngle: 将 currentAngle 更新为 endAngle,以便绘制下一个奖项区域。
记录角度信息: 在 drawPrizeWheel 方法中,我们为每个奖项对象添加了 startAngle 和 endAngle 属性,用来存储该奖项区域的起始和结束角度。
判断指针位置: 在 stopRotate 方法中,我们循环遍历 prizeList 数组,并根据每个奖项的 startAngle 和 endAngle 判断指针是否落在该区域内。
注意处理了跨越 0 度的情况,如果 startAngle 大于 endAngle,则表示该区域跨越了 0 度,需要分别判断指针是否大于等于 startAngle 或者小于 endAngle。
通过以上修改,奖项区域将从 0 度开始绘制,并且每个区域的角度信息会被正确记录在 prizeList 数组中,然后在 stopRotate方法中准确判断指针落在哪个区域,从而确定中奖结果。
转盘转动:
使用 setInterval/setTimeout 或 requestAnimationFrame 实现动画效果。
控制转速和停止位置。
<view class="pointer" style="transform: rotate({{pointerAngle}}deg)"></view>
//开始旋转
startRotate() {if (this.data.isRotating) return;this.setData({ isRotating: true });// 生成随机旋转圈数(至少旋转 6 圈)const randomRounds = 6 + Math.floor(Math.random() * 3); // 生成随机停止角度const finalAngle = Math.floor(Math.random() * 360);// 计算总旋转角度const totalRotation = randomRounds * 360 + finalAngle;// 使用 setInterval 实现动画const startTime = Date.now();const frameRate = 80; const rotateAnimation = () => {const currentTime = Date.now();const elapsed = currentTime - startTime;const progress = Math.min(elapsed / this.data.animationDuration, 1);// 使用 ease-out 动画曲线const easeOut = (t) => 1 - Math.pow(1 - t, 3);// 计算当前角度const currentAngle = easeOut(progress) * totalRotation;this.setData({ pointerAngle: currentAngle });if (progress < 1) {setTimeout(rotateAnimation, 1000 / frameRate); } else {this.stopRotate();}};rotateAnimation();
},
代码解释:
随机旋转圈数: 在 startRotate 方法中,我们使用 randomRounds 变量来生成一个随机的旋转圈数,至少 3 圈,最多 6 圈。
随机停止角度: 使用 finalAngle 变量生成一个 0 到 360 之间的随机角度,表示指针最终停止的位置。
计算总旋转角度: 将旋转圈数转换为角度,再加上最终停止角度,得到总旋转角度 totalRotation。
使用 setInterval/setTimeout 实现动画: 使用 setInterval/setTimeout 按照帧率更新指针角度,并使用 easeOut 动画曲线使指针旋转更自然。
停止动画和判断中奖区域: 在 stopRotate 方法中,根据最终指针角度 finalAngle 计算中奖区域索引,并弹出中奖信息。
旋转指针: wxml 中使用 style="transform: rotate({{pointerAngle}}deg)" 控制指针旋转,pointerAngle 存储指针旋转角度。
指针旋转中心: wxss 中设置 .pointer 的 transform-origin: 50% 100%; 将旋转中心点设置为指针底部。
stopRotate 方法: 修改逻辑,计算指针需要旋转到的角度 targetAngle,并更新 pointerAngle 数据。
现在,转盘指针将会随机旋转几圈,然后随机停止在某个角度,并显示指针指向的奖项作为中奖结果。
中奖判断:
根据停止位置计算中奖索引。
显示中奖弹窗,展示 prizeList 中对应的数据。
stopRotate() {let winningIndex = null;const prizeCount = this.data.prizeList.length;const anglePerItem = 360 / prizeCount;const finalAngle = this.data.pointerAngle;const pointerAngle = (finalAngle) % 360;// 循环遍历每个奖项区域,判断指针是否落在该区域内for (let i = 0; i < prizeCount; i++) {const { startAngle, endAngle } = this.data.prizeList[i];// 处理跨越 0 度的情况,跨越0度就是该区域的开始角度大于结束角度,如340-20if (startAngle > endAngle) {if (pointerAngle >= startAngle || pointerAngle < endAngle) {winningIndex = i;break;}} else {if (pointerAngle >= startAngle && pointerAngle < endAngle) {winningIndex = i;break;}}}this.setData({isRotating: false});const winningPrize = this.data.prizeList[winningIndex].name;wx.showModal({title: "恭喜!",content: `您获得了${winningPrize}!`,showCancel: false,});
},
代码解释:
获取旋转后的角度: finalAngle 表示指针旋转后的随机角度
计算中奖索引:将 finalAngle取余 360,得到相对于第一个奖项区域起始位置的角度pointerAngle 。
遍历奖项区域: 循环遍历每个奖项,计算每个奖项区域的起始角度 startAngle 和结束角度 endAngle。
判断指针位置: 判断 pointerAngle 是否落在当前奖项区域内 (startAngle 到 endAngle 之间)。
确定中奖索引: 如果指针落在某个奖项区域内,记录下该奖项的索引 winningIndex,并跳出循环。
处理结果: 根据 winningIndex 获取中奖信息,并进行后续处理。如果 winningIndex 为 null,则表示出现异常,需要进行相应的处理。
注意:
(startAngle 到 endAngle 之间)存在跨越0度的情况,要进行判断
如果你的奖项区域绘制顺序或方向与示例不同,你需要相应地调整计算逻辑。
希望这个解决方案可以帮助你!
编辑转盘:
弹窗中使用列表展示 prizeList 数据,可以进行增删改操作。
修改 prizeList 数据后,重新绘制转盘。
editPrize() {this.setData({showModal: true});},closeModal() {this.setData({showModal: false});},addNewPrize() {const colorsLength = this.data.colorsList.length;const random = Math.floor(Math.random() * colorsLength);this.setData({prizeList: [...this.data.prizeList, {name: '',color: this.data.colorsList[random] || '#000000'}],});},deletePrizeItem(e) {const index = e.currentTarget.dataset.index;let updatedPrizeList = [...this.data.prizeList];updatedPrizeList.splice(index, 1); // 从数组中移除对应奖项this.setData({prizeList: updatedPrizeList,});},updatePrizeName(e) {const index = e.currentTarget.dataset.index;const value = e.detail.value;let updatedPrizeList = this.data.prizeList;updatedPrizeList[index].name = value;this.setData({prizeList: updatedPrizeList,});},updatePrizeColor(e) {const index = e.currentTarget.dataset.index;const color = e.detail.value;let updatedPrizeList = this.data.prizeList;updatedPrizeList[index].color = color;this.setData({prizeList: updatedPrizeList,});},savePrizeList() {// 将 prizeList 转换为 JSON 字符串并存储// 这里可以根据你的需求修改存储方式const prizeListStr = JSON.stringify(this.data.prizeList);// console.log("保存的 JSON 字符串:", prizeListStr);this.closeModal();this.drawPrizeWheel(); // 重新绘制转盘},
4. 代码示例:
index.wxml
<view class="container"><view class="wheel-container"> <view class="prize-wheel"> <image class="canvas" src="{{wheelImg}}"></image></view><view class="pointer" style="transform: rotate({{pointerAngle}}deg)"></view> </view><button class="btn" bindtap="startRotate" disabled="{{isRotating}}">开始</button><button class="btn edit-btn" bindtap="editPrize">编辑</button><modal title="编辑奖项" hidden="{{!showModal}}" bindcancel="closeModal"><view class="edit-area"><!-- <view class="color-picker-slider"><view>R: <slider min="0" max="255" value="{{r}}" bindchange="onColorSliderChange" data-channel="r"/></view><view>G: <slider min="0" max="255" value="{{g}}" bindchange="onColorSliderChange" data-channel="g"/></view><view>B: <slider min="0" max="255" value="{{b}}" bindchange="onColorSliderChange" data-channel="b"/></view><view class="color-preview" style="background-color: rgb({{r}}, {{g}}, {{b}});"></view></view> --><view class="prize-item" wx:for="{{prizeList}}" wx:key="index"><input class="prize-name" placeholder="奖项名称" value="{{item.name}}" data-index="{{index}}"bindinput="updatePrizeName"/><input class="color-picker" type="color" value="{{item.color}}" data-index="{{index}}"bindchange="updatePrizeColor"/><button class="delete-btn" data-index="{{index}}" bindtap="deletePrizeItem">-</button>
</view>
</view><button class="btn add-btn" bindtap="addNewPrize">+</button><button class="btn save-btn" bindtap="savePrizeList">保存</button></modal>
</view>
index.js
Page({data: {prizeList: [{name: '一等奖',color: '#FFD700'},{name: '谢谢参与',color: '#C0C0C0'},{name: '二等奖',color: '#CD7F32'},{name: '谢谢参与',color: '#C0C0C0'},{name: '三等奖',color: '#A0522D'},{name: '谢谢参与',color: '#C0C0C0'},],prizeListStr: '',showModal: false,rotateAngle: 0, // 用于模拟转盘动画,实际并未使用animation: null, // 未使用pointerAngle: 0, // 指针角度isRotating: false, // 是否正在旋转animationDuration: 5000, // 动画持续时间 (毫秒)showColorPickerVisible: false,currentPrizeIndex: null,r: 0,g: 0,b: 0,colorsList: ['#FFD700', '#C0C0C0', '#CD7F32', '#A0522D', '#DC143C', '#FF69B4','#BA55D3', '#7B68EE', '#6A5ACD', '#483D8B', '#4682B4', '#00CED1','#5F9EA0', '#2E8B57', '#9ACD32', '#FFFF00', '#FFA500', '#FF4500','#8B4513', '#D2691E', '#B8860B', '#808000', '#556B2F', '#228B22','#008000', '#006400', '#90EE90', '#00FF7F', '#00FA9A', '#20B2AA','#235788', '#697656', '#D935E9', '#B69754', '#BFA476', '#BAC289','#983471', '#EA4586', '#EABCDE', '#ACBEAD', '#825673', '#65C946',],},onLoad() {this.drawPrizeWheel();},onReady() {},onColorSliderChange(e) {const channel = e.currentTarget.dataset.channel;const value = e.detail.value;this.setData({[channel]: value});},drawPrizeWheel() {// 创建离屏 2D canvas 实例,创建canvas元素const canvas = wx.createOffscreenCanvas({type: '2d', width: 300, height: 300});// 获取 context。注意这里必须要与创建时的 type 一致const ctx = canvas.getContext('2d');const centerX = 150;const centerY = 150;const radius = 130;const prizeCount = this.data.prizeList.length;const anglePerItem = 360 / prizeCount;// 记录当前角度,初始为 0/90 度let currentAngle = 90; for (let i = 0; i < prizeCount; i++) {ctx.beginPath();ctx.moveTo(centerX, centerY);ctx.arc(centerX,centerY,radius,(i * anglePerItem * Math.PI) / 180,((i + 1) * anglePerItem * Math.PI) / 180,);ctx.closePath();ctx.fillStyle = this.data.prizeList[i].color;ctx.fill();// --- 绘制文字 ---ctx.save(); // 保存当前画布状态ctx.font = '12px sans-serif';ctx.fillStyle = '#fff';ctx.textAlign = 'center';ctx.textBaseline = 'middle';const textAngle = (i * anglePerItem + anglePerItem / 2) * (Math.PI) / 180;const textX = centerX + radius * 0.7 * Math.cos(textAngle);const textY = centerY + radius * 0.7 * Math.sin(textAngle);ctx.fillText(this.data.prizeList[i].name, textX, textY);ctx.restore(); // 恢复之前的画布状态// --- 文字绘制结束 ---// 计算当前奖项区域的结束角度let endAngle = currentAngle + anglePerItem;if(endAngle > 360){endAngle %= 360;}// 计算当前奖项区域的起始角度和结束角度// 将角度信息存储到 prizeList 数组中this.data.prizeList[i].startAngle = currentAngle;this.data.prizeList[i].endAngle = endAngle;// 更新 currentAngle,准备绘制下一个区域currentAngle = endAngle; }// 将canvas转换为DataURL格式的图片var dataURL = canvas.toDataURL('image/png', 1);this.setData({wheelImg: dataURL});},startRotate() {if (this.data.isRotating) return;this.setData({ isRotating: true });// 生成随机旋转圈数(至少旋转 6 圈)const randomRounds = 6 + Math.floor(Math.random() * 3); // 生成随机停止角度const finalAngle = Math.floor(Math.random() * 360);// 计算总旋转角度const totalRotation = randomRounds * 360 + finalAngle;// 使用 setInterval 实现动画const startTime = Date.now();const frameRate = 80;const rotateAnimation = () => {const currentTime = Date.now();const elapsed = currentTime - startTime;const progress = Math.min(elapsed / this.data.animationDuration, 1);// 使用 ease-out 动画曲线const easeOut = (t) => 1 - Math.pow(1 - t, 3);// 计算当前角度const currentAngle = easeOut(progress) * totalRotation;this.setData({ pointerAngle: currentAngle });if (progress < 1) {setTimeout(rotateAnimation, 1000 / frameRate); } else {this.stopRotate();}};rotateAnimation();},stopRotate() {let winningIndex = null;const prizeCount = this.data.prizeList.length;const anglePerItem = 360 / prizeCount;const finalAngle = this.data.pointerAngle;const pointerAngle = (finalAngle) % 360;// 循环遍历每个奖项区域,判断指针是否落在该区域内for (let i = 0; i < prizeCount; i++) {const { startAngle, endAngle } = this.data.prizeList[i];// 处理跨越 0 度的情况,跨越0度就是该区域的开始角度大于结束角度,如340-20if (startAngle > endAngle) {if (pointerAngle >= startAngle || pointerAngle < endAngle) {winningIndex = i;break;}} else {if (pointerAngle >= startAngle && pointerAngle < endAngle) {winningIndex = i;break;}}}this.setData({isRotating: false});const winningPrize = this.data.prizeList[winningIndex].name;wx.showModal({title: "恭喜!",content: `您获得了${winningPrize}!`,showCancel: false,});},editPrize() {this.setData({showModal: true});},closeModal() {this.setData({showModal: false});},addNewPrize() {const colorsLength = this.data.colorsList.length;const random = Math.floor(Math.random() * colorsLength);this.setData({prizeList: [...this.data.prizeList, {name: '',color: this.data.colorsList[random] || '#000000'}],});},deletePrizeItem(e) {const index = e.currentTarget.dataset.index;let updatedPrizeList = [...this.data.prizeList];updatedPrizeList.splice(index, 1); // 从数组中移除对应奖项this.setData({prizeList: updatedPrizeList,});},updatePrizeName(e) {const index = e.currentTarget.dataset.index;const value = e.detail.value;let updatedPrizeList = this.data.prizeList;updatedPrizeList[index].name = value;this.setData({prizeList: updatedPrizeList,});},shufflePrizeList() {let updatedPrizeList = [...this.data.prizeList];for (let i = updatedPrizeList.length - 1; i > 0; i--) {const j = Math.floor(Math.random() * (i + 1));[updatedPrizeList[i], updatedPrizeList[j]] = [updatedPrizeList[j], updatedPrizeList[i]];}this.setData({prizeList: updatedPrizeList,});},updatePrizeColor(e) {const index = e.currentTarget.dataset.index;const color = e.detail.value;let updatedPrizeList = this.data.prizeList;updatedPrizeList[index].color = color;this.setData({prizeList: updatedPrizeList,});},savePrizeList() {// 将 prizeList 转换为 JSON 字符串并存储// 这里可以根据你的需求修改存储方式const prizeListStr = JSON.stringify(this.data.prizeList);// console.log("保存的 JSON 字符串:", prizeListStr);this.shufflePrizeList(); //打乱奖项的排序,实现随机性this.closeModal();this.drawPrizeWheel(); // 重新绘制转盘},
})
index.wxss
.container {display: flex;flex-direction: column;align-items: center;justify-content: center;height: auto;padding-bottom: 0;padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
}
.wheel-container {margin-top: 50rpx;width: 300px;height: 300px;position: relative;
}
.prize-wheel {width: 100%;height: 100%;
}
.canvas {width: 100%;height: 100%;
}
.pointer {width: 0px;height: 0px;border-bottom: 50px solid #ff0000;border-left: 10px solid transparent;border-right: 10px solid transparent;/* background-color: red; */position: absolute;bottom: 50%;left: 50%;transform-origin: 50% 100%; /* 设置旋转中心点为指针底部 */z-index: 10;margin-left: -10px; /* 添加 margin-left */margin-top: -10px;
}
.pointer::before{content: "";width: 20px;height: 20px;border: 0;padding: 0;border-radius: 50%;background-color: #ff0000;position: absolute;bottom: -60px;left: -10px;z-index: 10;
}
.btn {margin: 10px;padding: 10px 20px;border: none;border-radius: 5px;background-color: #007bff;color: #fff;font-size: 16px;
}
.edit-btn {background-color: #28a745;
}
.save-btn {background-color: #28a745;margin-top: 20px;
}
.add-btn {background-color: #1989fa;margin-top: 10px;
}
.color-preview {width: 50px;height: 20px;border: 1px solid #ccc;
}
.edit-area {padding: 10rpx;overflow: auto;height: auto;max-height: 620rpx;
}
.delete-btn {background-color: #dc3545;/* 红色背景 */color: #fff;/* 白色文字 */border: none;border-radius: 50%;/* 圆形按钮 */display: flex;align-items: center;justify-content: center;width: 55rpx !important;height: 55rpx !important;padding: 0;line-height: 55rpx;text-align: center;font-size: 12px;margin-left: 5px;
}
.prize-item {display: flex;align-items: center;margin-bottom: 10px;
}
.prize-name,
.color-picker {flex: 1;padding: 5px;border: 1px solid #ccc;margin-right: 5px;
}
注意: 以上代码只是一个示例,你需要根据实际需求进行调整和完善,例如:
添加动画效果,例如转盘加速、减速。
处理用户交互,例如点击开始按钮后禁用按钮,防止重复点击。
优化代码结构,提高代码可读性和可维护性。
另外,我们可以添加一个方法来打乱 prizeList 数组的顺序,从而增加抽奖的随机性。
在 page 对象中添加以下方法:
// ... 其他代码 ...shufflePrizeList() {let updatedPrizeList = [...this.data.prizeList];for (let i = updatedPrizeList.length - 1; i > 0; i--) {const j = Math.floor(Math.random() * (i + 1));[updatedPrizeList[i], updatedPrizeList[j]] = [updatedPrizeList[j], updatedPrizeList[i]];}this.setData({prizeList: updatedPrizeList,});},
// ... 其他代码 ...
方法说明:
shufflePrizeList 方法使用 Fisher-Yates shuffle 算法来随机打乱数组元素的顺序。该算法会遍历数组,并将当前元素与随机选取的另一个元素交换位置。
调用该方法:
你可以在以下几种情况下调用 shufflePrizeList 方法:
在开始旋转转盘之前调用: 这样每次点击 "开始" 按钮时,奖项的顺序都会被打乱,增加随机性。
startRotate() {if (this.data.isRotating) return;this.shufflePrizeList(); // 打乱奖项顺序// ... 其他代码 ...
},
在保存奖项列表之后调用: 这样每次编辑完奖项并保存后,奖项的顺序也会被打乱。
savePrizeList() {// ... 保存奖项列表逻辑 ...this.shufflePrizeList(); // 打乱奖项顺序this.closeModal();this.drawPrizeWheel();
},
在其他合适的时机调用: 根据你的需求,你也可以在其他时机调用该方法,例如在页面加载完成后调用。
选择一个合适的时机调用 shufflePrizeList 方法,就可以实现打乱奖项顺序,提高抽奖的概率性了。
你也可以进行点修改,实现指针不懂,转盘转动。
希望以上信息能够帮助你! 😊