uniapp 抽奖

925 阅读3分钟

转盘抽奖

效果

PixPin_2025-04-19_16-01-09.webp

一个简单的转盘抽奖代码,有可配置的参数

  • 指定转盘大小
  • 抽奖可指定奖品的抽中概率
  • 可指定抽奖时转盘的旋转圈数
  • 可指定抽奖时转盘旋转的时间

代码

<template>
	<view class="lottery-box">
		<!-- 转盘 -->
		<image
			src="/static/images/circle.png"
			class="turntable"
			mode="scaleToFill"
			:animation="turntableAnimationData"
		/>
		<view :animation="animationData" class="turntable-box">
			<!-- 分割线,用于辅助查看转盘分割是否正确 -->
			<view class="turntable-line">
				<view
					class="turntable-line-item"
					v-for="(item, index) in list"
					:key="index"
					:style="{ transform: 'rotate(' + (index * width + width / 2) + 'deg)' }"
				></view>
			</view>

			<!-- 奖品列表 -->
			<view class="turntable-list">
				<view
					class="turntable-list-item"
					:style="{
						transform: 'rotate(' + index * width + 'deg)',
						zIndex: index,
					}"
					v-for="(iteml, index) in list"
					:key="index"
				>
					<view class="turntable-list-item-box" :style="{ transform: 'rotate(' + index + ')' }">
						<div>
							{{ iteml.name }}
						</div>
					</view>
				</view>
			</view>
		</view>

		<!-- 中间的指针/抽奖按钮 -->
		<image src="/static/images/GO.png" class="btn" mode="scaleToFill" @click="playReward" />
	</view>
</template>

<script setup>
	import { ref, onMounted, computed } from 'vue';
	import { onLoad } from '@dcloudio/uni-app';
	const { proxy } = getCurrentInstance();

	onMounted(() => {
		// 获取奖品列表
		width.value = 360 / list.value.length;
		// 验证概率
		validateProbability();
	});

	// 抽奖配置
	const config = ref({
		circles: 5, // 旋转圈数,默认5圈
		duration: 1000 * 5, // 旋转时间,单位毫秒,默认5秒
		size: 700, // 转盘大小,单位rpx,默认700rpx
	});

	// 计算转盘中心点位置(用于分隔线定位和奖品定位)
	const centerPoint = computed(() => config.value.size / 2);

	// 奖品列表
	const list = ref([
		{
			name: '5折',
			probability: 1,
		},
		{
			name: '6折',
			probability: 1,
		},
		{
			name: '7折',
			probability: 1,
		},
		{
			name: '8折',
			probability: 1,
		},
		{
			name: '9折',
			probability: 1,
		},
		{
			name: '感谢参与',
			isNoPrize: true,
			probability: 95,
		},
	]);

	const width = ref(0);
	const animationData = ref({}); // 奖品动画
	const turntableAnimationData = ref({}); // 背景圆盘动画
	const btnDisabled = ref(false); // 按钮是否禁用
	let runDeg = 0; // 旋转角度

	// 验证概率总和是否为100
	const validateProbability = () => {
		const total = list.value.reduce((sum, item) => sum + item.probability, 0);
		if (total !== 100) {
			uni.showToast({
				title: '奖品概率总和必须为100%',
				icon: 'none',
			});
			return false;
		}
		return true;
	};

	// 根据概率获取中奖索引
	const getLuckyIndex = () => {
		const random = Math.random() * 100;
		let currentSum = 0;

		for (let i = 0; i < list.value.length; i++) {
			currentSum += list.value[i].probability;
			if (random <= currentSum) {
				return i;
			}
		}
		return list.value.length - 1;
	};

	// 创建抽奖动画
	const animation = (index, duration) => {
		const currentList = list.value;
		const runNum = config.value.circles; // 使用配置的圈数

		// 旋转角度
		runDeg =
			runDeg + (360 - (runDeg % 360)) + (360 * runNum - index * (360 / currentList.length)) + 1;

		// 创建奖品动画
		const animationRun = uni.createAnimation({
			duration: duration,
			timingFunction: 'ease',
		});
		animationRun.rotate(runDeg).step();
		animationData.value = animationRun.export();

		// 创建背景圆盘动画
		const turntableAnimation = uni.createAnimation({
			duration: duration,
			timingFunction: 'ease',
		});
		turntableAnimation.rotate(runDeg).step();
		turntableAnimationData.value = turntableAnimation.export();
	};

	// 发起抽奖
	async function playReward() {
		if (btnDisabled.value) return;
                
                btnDisabled.value = true; // 按钮禁用

		// 验证概率
		if (!validateProbability()) return;

		// 获取中奖索引
		const index = getLuckyIndex();
		const duration = config.value.duration; // 使用配置的时间

		animation(index, duration);

		await new Promise((resolve) => setTimeout(resolve, duration + 1000));

		uni.showModal({
			content: list.value[index].isNoPrize
				? '抱歉,您未中奖'
				: `恭喜,获得${list.value[index].name}`,
		});
		btnDisabled.value = false;
	}
</script>

<style lang="scss" scoped>
	.lottery-box {
		position: relative;
		width: v-bind('config.size + "rpx"');
		height: v-bind('config.size + "rpx"');
	}

	// 转盘
	.turntable {
		position: absolute;
		left: 0;
		top: 0;
		width: 100%;
		height: 100%;
		border-radius: 50%;
	}

	// 转盘容器
	.turntable-box {
		position: absolute;
		left: 0;
		top: 0;
		z-index: 1;
		display: block;
		width: 100%;
		height: 100%;
		border-radius: inherit;
	}

	/* 分隔线 开始 */
	.turntable-line {
		position: absolute;
		left: 0;
		top: 0;
		width: inherit;
		height: inherit;
		z-index: 99;
	}

	.turntable-line-item {
		position: absolute;
		left: v-bind('centerPoint + "rpx"');
		top: 0;
		width: 3rpx;
		height: v-bind('centerPoint + "rpx"');
		background-color: rgba(228, 55, 14, 0.4);
		overflow: hidden;
		transform-origin: 50% v-bind('centerPoint + "rpx"');
	}
	/* 分隔线 结束 */

	/* 奖品列表 开始 */
	.turntable-list {
		position: absolute;
		left: 0;
		top: 0;
		width: inherit;
		height: inherit;
		z-index: 9999;
	}

	.turntable-list-item {
		position: absolute;
		left: 0;
		top: 0;
		width: 100%;
		height: 100%;
		color: #e4370e;
	}

	.turntable-list-item-box {
		position: relative;
		display: block;
		padding-top: v-bind('(centerPoint * 0.15) + "rpx"');
		margin: 0 auto;
		text-align: center;
		transform-origin: 50% v-bind('centerPoint + "rpx"');
		display: flex;
		flex-direction: column;
		align-items: center;
		color: #fb778b;
	}
	/* 奖品列表 结束 */

	/* 抽奖按钮 开始 */
	.btn {
		position: absolute;
		top: 50%;
		left: 50%;
		transform: translate(-50%, -50%);
		width: v-bind('(config.size * 0.167) + "rpx"');
		height: v-bind('(config.size * 0.167) + "rpx"');
		z-index: 400;
	}
	/* 抽奖按钮 结束 */
</style>

注意:

  • 需要根据项目需求自行修改
  • demo代码中用到了两张图片,一张是转盘图片一张是抽奖按钮图片

九宫格抽奖

效果

PixPin_2025-04-22_15-59-05.webp

一个简单的九宫格抽奖代码,有可配置的参数

  • 抽奖动画的初始速度
  • 抽奖可指定奖品的抽中概率
  • 可指定抽奖时旋转的圈数
  • 可指定抽奖的动画时间
  • 可指定是否每次都从第一个奖品开始抽奖

代码

<template>
	<view class="lottery-container">
		<view class="lottery-box">
			<template v-for="(item, index) in 9" :key="index">
				<!-- 奖品 -->
				<template v-if="index !== 4">
					<view class="lottery-item" :class="{ active: currentIndex === index }">
						<view class="lottery-content">
							<text>{{ getItemName(index) }}</text>
						</view>
					</view>
				</template>
				<!-- 中间的抽奖按钮 -->
				<template v-else>
					<view class="lottery-btn" @click="playReward" :class="{ 'btn-disabled': btnDisabled }">
						<text>{{ btnDisabled ? '抽奖中...' : '开始' }}</text>
					</view>
				</template>
			</template>
		</view>
	</view>
</template>

<script setup>
	import { ref, onMounted, computed, getCurrentInstance, onUnmounted } from 'vue';
	import { onLoad } from '@dcloudio/uni-app';

	const { proxy } = getCurrentInstance();

	onMounted(() => {
		if (list.value.length != 8) {
			uni.showToast({
				title: '奖品数量必须为8个',
				icon: 'none',
			});
			return;
		}
		validateProbability();
		// 初始化lastStopIndex为0(第一个奖品位置)
		lastStopIndex.value = 0;
	});

	// 组件卸载时清理定时器
	onUnmounted(() => {
		if (timer) {
			clearTimeout(timer);
			timer = null;
		}
	});

	// 抽奖配置
	const config = ref({
		speed: 50, // 初始速度(ms)
		duration: 5000, // 动画总时间(ms)
		circles: 5, // 总圈数
		isInit: false, // 是否每次都从第一个奖品开始转
	});

	// 奖品列表 - 8个奖品,按照九宫格顺序排列(中间为开始按钮)
	const list = ref([
		{ name: '1元红包', probability: 0 },
		{ name: '2元红包', probability: 0 },
		{ name: '3元红包', probability: 0 },
		{ name: '4元红包', probability: 0 },
		{ name: '5元红包', probability: 0 },
		{ name: '6元红包', probability: 0 },
		{ name: '7元红包', probability: 0 },
		{ name: '谢谢参与', probability: 100, isNoPrize: true },
	]);

	// 九宫格顺序路径 - 顺时针旋转,跳过中间位置
	// 九宫格布局:
	// [0] [1] [2]
	// [3] [4] [5]
	// [6] [7] [8]
	const path = [0, 1, 2, 5, 8, 7, 6, 3]; // 顺时针路径

	const currentIndex = ref(-1); // 当前选中索引
	const btnDisabled = ref(false); // 按钮是否禁用
	let timer = null; // 定时器
	let currentSpeed = config.value.speed; // 当前速度
	let cycles = 0; // 当前循环次数

	// 记录上一次停止的位置
	const lastStopIndex = ref(0);

	// 验证概率总和是否为100
	const validateProbability = () => {
		const total = list.value.reduce((sum, item) => sum + item.probability, 0);
		if (total !== 100) {
			uni.showToast({
				title: '奖品概率总和必须为100%',
				icon: 'none',
			});
			return false;
		}
		return true;
	};

	// 根据概率获取中奖索引
	const getLuckyIndex = () => {
		const random = Math.random() * 100;
		let currentSum = 0;

		for (let i = 0; i < list.value.length; i++) {
			currentSum += list.value[i].probability;
			if (random <= currentSum) {
				return i;
			}
		}
		return list.value.length - 1;
	};

	// 获取实际奖品索引
	const getItemIndex = (gridIndex) => {
		// 九宫格位置与奖品索引的映射关系
		const mapping = {
			0: 0, // 左上
			1: 1, // 中上
			2: 2, // 右上
			3: 3, // 左中
			4: -1, // 中间(抽奖按钮)
			5: 4, // 右中
			6: 5, // 左下
			7: 6, // 中下
			8: 7, // 右下
		};
		return mapping[gridIndex];
	};

	// 获取奖品名称
	const getItemName = (gridIndex) => {
		const itemIndex = getItemIndex(gridIndex);
		if (itemIndex === -1) return '';
		return list.value[itemIndex]?.name || '';
	};

	// 动画逻辑
	const runAnimation = (targetIndex) => {
		let pathIndex = 0;
		let startTime = Date.now();
		let isPreCircle = false;
		let hasReachedFirst = config.value.isInit;

		const needPreCircle = !config.value.isInit && lastStopIndex.value !== 0;
		const actualCircles = needPreCircle ? config.value.circles + 1 : config.value.circles;

		const run = async () => {
			// 如果需要预转圈且还没到第一个奖品
			if (needPreCircle && !hasReachedFirst) {
				currentIndex.value = path[pathIndex];

				// 如果转到了第一个奖品位置
				if (path[pathIndex] === 0) {
					hasReachedFirst = true;
					isPreCircle = false;
					cycles = 0;
					startTime = Date.now();
				}

				pathIndex = (pathIndex + 1) % path.length;
				timer = setTimeout(run, config.value.speed);
				return;
			}

			currentIndex.value = path[pathIndex];

			// 计算当前圈数
			const currentCircles = Math.floor(cycles / path.length);

			// 如果达到目标圈数且到达目标位置,则停止
			if (currentCircles >= config.value.circles && path[pathIndex] === targetIndex) {
				clearTimeout(timer);
				timer = null;
				btnDisabled.value = false;
				lastStopIndex.value = targetIndex;

				// 显示中奖信息
				await new Promise((resolve) => setTimeout(resolve, 500));
				const itemIndex = getItemIndex(targetIndex);
				uni.showModal({
					content: list.value[itemIndex].isNoPrize
						? '抱歉,您未中奖'
						: `恭喜,获得${list.value[itemIndex].name}`,
				});
				return;
			}

			// 计算当前速度
			// 使用指数函数实现渐进式减速
			const totalSteps = config.value.circles * path.length;
			const currentStep = cycles;
			const progress = currentStep / totalSteps;

			// 使用指数函数计算速度因子,让减速更加平滑
			const speedFactor = Math.pow(progress, 1.5); // 指数1.5提供较为平缓的减速效果

			// 初始速度到最终速度的变化范围更大,让减速效果更明显
			// 最大减速到初始速度的5倍
			currentSpeed = config.value.speed * (1 + speedFactor * 4);

			pathIndex = (pathIndex + 1) % path.length;
			cycles++;

			timer = setTimeout(run, currentSpeed);
		};

		// 设置初始pathIndex
		if (!config.value.isInit && lastStopIndex.value !== 0) {
			pathIndex = path.indexOf(lastStopIndex.value);
		}

		run();
	};

	// 开始抽奖
	const playReward = () => {
		if (btnDisabled.value || timer !== null) return;
		if (!validateProbability()) return;

		btnDisabled.value = true;
		cycles = 0;
		currentSpeed = config.value.speed;

		// 获取中奖索引
		const luckyIndex = getLuckyIndex();
		// 将奖品索引转换为九宫格位置索引
		let targetGridIndex = -1;
		for (let i = 0; i < 9; i++) {
			if (getItemIndex(i) === luckyIndex) {
				targetGridIndex = i;
				break;
			}
		}

		// 确保目标位置在路径数组中
		if (!path.includes(targetGridIndex)) {
			console.error('目标位置不在路径中');
			btnDisabled.value = false;
			return;
		}

		runAnimation(targetGridIndex);
	};
</script>

<style lang="scss" scoped>
	.lottery-container {
		padding: 20rpx;
	}

	.lottery-box {
		position: relative;
		width: 600rpx;
		height: 600rpx;
		margin: 0 auto;
		display: grid;
		grid-template-columns: repeat(3, 1fr);
		grid-template-rows: repeat(3, 1fr);
		gap: 10rpx;
		background-color: #f5f5f5;
		padding: 10rpx;
		border-radius: 20rpx;
	}

	.lottery-item {
		background-color: #ffffff;
		border-radius: 10rpx;
		display: flex;
		align-items: center;
		justify-content: center;
		transition: all 0.1s ease;

		&.active {
			background-color: #ffe4e1;
			transform: scale(0.95);
		}
	}

	.lottery-content {
		text-align: center;
		color: #333;
		font-size: 28rpx;
	}

	.lottery-btn {
		width: 180rpx;
		height: 180rpx;
		background-color: #ff4444;
		border-radius: 50%;
		display: flex;
		align-items: center;
		justify-content: center;
		color: #ffffff;
		font-size: 32rpx;
		font-weight: bold;
		box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.1);
		transition: all 0.3s ease;

		&.btn-disabled {
			background-color: #cccccc;
			pointer-events: none;
		}

		&:active {
			transform: scale(0.8);
		}
	}
</style>