介绍
提供了几个组合式函数,封装了九宫格、幸运大转盘、老虎机等抽奖的主要逻辑。布局与样式需自行实现,可拷贝案例代码再进行修改。
代码演示
九宫格
遍历 useLuckyGrid 返回的 grids 计算属性来渲染九宫格;activeIndex 计算属性用于判断当前活动的奖品。
点击按钮后调用 play 方法开始动画;
在确定抽中的奖品下标时调用 stop 方法开始减速动画;
在动画完全停止后调用 complete 回调函数展示抽中的奖品。
vue
<template>
<div class="grid-box">
<div v-for="item in grids" :key="item" class="grid-item">
<div v-if="item > -1" :class="['prize-item', { active: item === activeIndex }]">
<div class="prize-icon">
<span :class="['cake', `cake-${prizes[item]?.icon}`]"></span>
</div>
<div class="prize-name">{{ prizes[item]?.name }}</div>
</div>
<div v-else class="play-btn" @click="onPlay()">点我抽奖</div>
</div>
</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize" class="dialog-prize">
<div class="dialog-prize-icon">
<span :class="['cake', `cake-${winningPrize.icon}`]"></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">“{{ winningPrize.name }}”</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="ts">
import { useLuckyGrid } from 'sard'
import { onMounted, ref } from 'vue'
import { getPrizesApi, getPrizeApi, type Prize } from './utils'
const prizes = ref<Prize[]>([])
const winningPrize = ref<Prize>()
const dialogVisible = ref(false)
const { grids, activeIndex, play, stop, pause } = useLuckyGrid({
complete: (index) => {
winningPrize.value = prizes.value[index]
dialogVisible.value = true
},
})
const onPlay = () => {
play()
getPrizeApi(8)
.then((prize) => {
stop(prizes.value.findIndex((item) => item.id === prize.id))
})
.catch(() => pause())
}
onMounted(() => {
getPrizesApi(8).then((res) => {
prizes.value = res
})
})
</script>
<style lang="scss" scoped>
.grid-box {
display: flex;
flex-wrap: wrap;
width: 330px;
height: 330px;
margin: 0 auto;
padding: 5px;
border: 5px solid #ffddcf;
border-radius: 16px;
background-color: #fffbef;
}
.grid-item {
width: 33.3333%;
padding: 5px;
}
.prize-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
background-color: #fff0cb;
border: 1px solid rgba(0, 0, 0, 0.02);
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.1);
&.active {
background-color: #ffd166;
}
}
.prize-icon {
font-size: 36px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 10px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 0;
}
.dialog-prize-icon {
font-size: 80px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
line-height: 16px;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>vue
<template>
<div class="grid-box">
<div v-for="item in grids" :key="item" class="grid-item">
<div v-if="item > -1" :class="['prize-item', { active: item === activeIndex }]">
<div class="prize-icon">
<span :class="['cake', `cake-${prizes[item]?.icon}`]"></span>
</div>
<div class="prize-name">{{ prizes[item]?.name }}</div>
</div>
<div v-else class="play-btn" @click="onPlay()">点我抽奖</div>
</div>
</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize" class="dialog-prize">
<div class="dialog-prize-icon">
<span :class="['cake', `cake-${winningPrize.icon}`]"></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">“{{ winningPrize.name }}”</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="js">
import { useLuckyGrid } from 'sard'
import { onMounted, ref } from 'vue'
import { getPrizesApi, getPrizeApi } from './utils'
const prizes = ref([])
const winningPrize = ref()
const dialogVisible = ref(false)
const { grids, activeIndex, play, stop, pause } = useLuckyGrid({
complete: (index) => {
winningPrize.value = prizes.value[index]
dialogVisible.value = true
},
})
const onPlay = () => {
play()
getPrizeApi(8)
.then((prize) => {
stop(prizes.value.findIndex((item) => item.id === prize.id))
})
.catch(() => pause())
}
onMounted(() => {
getPrizesApi(8).then((res) => {
prizes.value = res
})
})
</script>
<style lang="scss" scoped>
.grid-box {
display: flex;
flex-wrap: wrap;
width: 330px;
height: 330px;
margin: 0 auto;
padding: 5px;
border: 5px solid #ffddcf;
border-radius: 16px;
background-color: #fffbef;
}
.grid-item {
width: 33.3333%;
padding: 5px;
}
.prize-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
background-color: #fff0cb;
border: 1px solid rgba(0, 0, 0, 0.02);
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.1);
&.active {
background-color: #ffd166;
}
}
.prize-icon {
font-size: 36px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 10px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 0;
}
.dialog-prize-icon {
font-size: 80px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
line-height: 16px;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>utils.ts
ts
import { random } from 'sard'
export function getPrizes() {
return [
{ id: 1, name: '华夫饼', icon: 'huafubing' },
{ id: 2, name: '芝士蛋糕', icon: 'zhishidangao' },
{ id: 3, name: '曲奇饼干', icon: 'quqibinggan' },
{ id: 4, name: '布丁', icon: 'buding' },
{ id: 5, name: '汉堡', icon: 'hanbao' },
{ id: 6, name: '吐司', icon: 'tusi' },
{ id: 7, name: '提拉米苏', icon: 'tilamisu' },
{ id: 8, name: '三明治', icon: 'sanmingzhi' },
{ id: 9, name: '泡芙', icon: 'paofu' },
{ id: 10, name: '千层蛋糕', icon: 'qiancengdangao' },
{ id: 11, name: '蛋挞', icon: 'danta' },
{ id: 12, name: '虎皮卷', icon: 'hupijuan' },
{ id: 13, name: '果冻', icon: 'guodong' },
{ id: 14, name: '蛋黄酥', icon: 'danhuangsu' },
{ id: 15, name: '巧克力蛋卷', icon: 'qiaokelidanjuan' },
{ id: 16, name: '苏打饼干', icon: 'sudabinggan' },
{ id: 17, name: '奥利奥', icon: 'aoliao' },
{ id: 18, name: '巧克力', icon: 'qiaokeli' },
]
}
export interface Prize {
id: number
name: string
icon: string
}
export const getPrizesApi = async (count: number) => {
return new Promise<Prize[]>((resolve) => setTimeout(resolve, 150, getPrizes().slice(0, count)))
}
export const getPrizeApi = async (count: number) => {
return new Promise<Prize>((resolve) =>
setTimeout(resolve, 150, getPrizes()[random(0, count - 1)]),
)
}
export const getMultiPrizeApi = async (columns: number[]) => {
return new Promise<Prize[]>((resolve) =>
setTimeout(
resolve,
150,
columns.map((column) => getPrizes()[random(0, column - 1)]),
),
)
}js
import { random } from 'sard'
export function getPrizes() {
return [
{
id: 1,
name: '华夫饼',
icon: 'huafubing',
},
{
id: 2,
name: '芝士蛋糕',
icon: 'zhishidangao',
},
{
id: 3,
name: '曲奇饼干',
icon: 'quqibinggan',
},
{
id: 4,
name: '布丁',
icon: 'buding',
},
{
id: 5,
name: '汉堡',
icon: 'hanbao',
},
{
id: 6,
name: '吐司',
icon: 'tusi',
},
{
id: 7,
name: '提拉米苏',
icon: 'tilamisu',
},
{
id: 8,
name: '三明治',
icon: 'sanmingzhi',
},
{
id: 9,
name: '泡芙',
icon: 'paofu',
},
{
id: 10,
name: '千层蛋糕',
icon: 'qiancengdangao',
},
{
id: 11,
name: '蛋挞',
icon: 'danta',
},
{
id: 12,
name: '虎皮卷',
icon: 'hupijuan',
},
{
id: 13,
name: '果冻',
icon: 'guodong',
},
{
id: 14,
name: '蛋黄酥',
icon: 'danhuangsu',
},
{
id: 15,
name: '巧克力蛋卷',
icon: 'qiaokelidanjuan',
},
{
id: 16,
name: '苏打饼干',
icon: 'sudabinggan',
},
{
id: 17,
name: '奥利奥',
icon: 'aoliao',
},
{
id: 18,
name: '巧克力',
icon: 'qiaokeli',
},
]
}
export const getPrizesApi = async (count) => {
return new Promise((resolve) => setTimeout(resolve, 150, getPrizes().slice(0, count)))
}
export const getPrizeApi = async (count) => {
return new Promise((resolve) => setTimeout(resolve, 150, getPrizes()[random(0, count - 1)]))
}
export const getMultiPrizeApi = async (columns) => {
return new Promise((resolve) =>
setTimeout(
resolve,
150,
columns.map((column) => getPrizes()[random(0, column - 1)]),
),
)
}自定义宫格行列
useLuckyGrid 默认会生成 3x3 个格子,也可以自定义行列数。
宫格的外边缘格子下标从左上角以 0 开始逆时针递增,内部的格子下标从左到右、从上到下以 -1 开始递减。
centerSize 计算属性可用于获取中间格子的行列数。
vue
<template>
<div class="grid-box">
<div v-for="item in grids" :key="item" class="grid-item" :style="{ width: 100 / column + '%' }">
<div
class="grid-item-inner"
:style="{
width: item === -1 ? centerSize.column * 100 + '%' : '',
height: item === -1 ? centerSize.row * 100 + '%' : '',
}"
>
<div v-if="item > -1" :class="['prize-item', { active: item === activeIndex }]">
<div class="prize-icon">
<span :class="['cake', `cake-${prizes[item]?.icon}`]"></span>
</div>
<div class="prize-name">{{ prizes[item]?.name }}</div>
</div>
<div v-else-if="item === -1" class="play-btn" @click="onPlay()">点我抽奖</div>
</div>
</div>
</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize" class="dialog-prize">
<div class="dialog-prize-icon">
<span :class="['cake', `cake-${winningPrize.icon}`]"></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">“{{ winningPrize.name }}”</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="ts">
import { useLuckyGrid, getGridPrizeCount } from 'sard'
import { computed, ref } from 'vue'
import { getPrizeApi, getPrizes, type Prize } from './utils'
const row = ref(4)
const column = ref(5)
const prizes = computed(() => {
return getPrizes().slice(0, getGridPrizeCount(row.value, column.value))
})
const winningPrize = ref<Prize>()
const dialogVisible = ref(false)
const { grids, activeIndex, centerSize, play, stop, pause } = useLuckyGrid({
row,
column,
complete: (index) => {
winningPrize.value = prizes.value[index]
dialogVisible.value = true
},
})
const onPlay = () => {
play()
getPrizeApi(getGridPrizeCount(row.value, column.value))
.then((prize) => {
stop(prizes.value.findIndex((item) => item.id === prize.id))
})
.catch(() => pause())
}
</script>
<style lang="scss" scoped>
.grid-box {
display: flex;
flex-wrap: wrap;
width: 330px;
height: 300px;
margin: 0 auto;
padding: 5px;
border: 5px solid #ffddcf;
border-radius: 16px;
background-color: #fffbef;
}
.grid-item {
position: relative;
width: 33.3333%;
}
.grid-item-inner {
width: 100%;
height: 100%;
padding: 5px;
}
.prize-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
background-color: #fff0cb;
border: 1px solid rgba(0, 0, 0, 0.02);
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.1);
&.active {
background-color: #ffd166;
}
}
.prize-icon {
font-size: 24px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 6px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 0;
}
.dialog-prize-icon {
font-size: 80px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
line-height: 16px;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>vue
<template>
<div class="grid-box">
<div v-for="item in grids" :key="item" class="grid-item" :style="{ width: 100 / column + '%' }">
<div
class="grid-item-inner"
:style="{
width: item === -1 ? centerSize.column * 100 + '%' : '',
height: item === -1 ? centerSize.row * 100 + '%' : '',
}"
>
<div v-if="item > -1" :class="['prize-item', { active: item === activeIndex }]">
<div class="prize-icon">
<span :class="['cake', `cake-${prizes[item]?.icon}`]"></span>
</div>
<div class="prize-name">{{ prizes[item]?.name }}</div>
</div>
<div v-else-if="item === -1" class="play-btn" @click="onPlay()">点我抽奖</div>
</div>
</div>
</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize" class="dialog-prize">
<div class="dialog-prize-icon">
<span :class="['cake', `cake-${winningPrize.icon}`]"></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">“{{ winningPrize.name }}”</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="js">
import { useLuckyGrid, getGridPrizeCount } from 'sard'
import { computed, ref } from 'vue'
import { getPrizeApi, getPrizes } from './utils'
const row = ref(4)
const column = ref(5)
const prizes = computed(() => {
return getPrizes().slice(0, getGridPrizeCount(row.value, column.value))
})
const winningPrize = ref()
const dialogVisible = ref(false)
const { grids, activeIndex, centerSize, play, stop, pause } = useLuckyGrid({
row,
column,
complete: (index) => {
winningPrize.value = prizes.value[index]
dialogVisible.value = true
},
})
const onPlay = () => {
play()
getPrizeApi(getGridPrizeCount(row.value, column.value))
.then((prize) => {
stop(prizes.value.findIndex((item) => item.id === prize.id))
})
.catch(() => pause())
}
</script>
<style lang="scss" scoped>
.grid-box {
display: flex;
flex-wrap: wrap;
width: 330px;
height: 300px;
margin: 0 auto;
padding: 5px;
border: 5px solid #ffddcf;
border-radius: 16px;
background-color: #fffbef;
}
.grid-item {
position: relative;
width: 33.3333%;
}
.grid-item-inner {
width: 100%;
height: 100%;
padding: 5px;
}
.prize-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
background-color: #fff0cb;
border: 1px solid rgba(0, 0, 0, 0.02);
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.1);
&.active {
background-color: #ffd166;
}
}
.prize-icon {
font-size: 24px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 6px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 12px;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 0;
}
.dialog-prize-icon {
font-size: 80px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
line-height: 16px;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>大转盘
大转盘与宫格抽奖逻辑类似。
count 选项用于指定奖品数量;
useLuckyWheel 函数返回的 sectorDegrees 计算属性为每个奖品所占的角度;
degrees 计算属性为整个大转盘当前旋转的角度。
vue
<template>
<div class="disc">
<div
class="sectors"
:style="{
backgroundImage: repeatingConicGradient,
transform: `rotate(${degrees}deg)`,
}"
>
<div
v-for="(prize, index) in prizes"
:key="prize.id"
class="sector"
:class="{ 'sector-event': index % 2 === 0 }"
:style="{
transform: `rotate(${-index * sectorDegrees}deg)`,
}"
>
<div class="sector-half">
<div class="prize-icon">
<span :class="['cake', `cake-${prize.icon}`]"></span>
</div>
<div class="prize-name">{{ prize.name }}</div>
</div>
</div>
</div>
<div class="play-btn" @click="onPlay">
<span>点我抽奖</span>
<div class="play-btn-arrow"></div>
</div>
</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize" class="dialog-prize">
<div class="dialog-prize-icon">
<span :class="['cake', `cake-${winningPrize.icon}`]"></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">“{{ winningPrize.name }}”</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="ts">
import { useLuckyWheel } from 'sard'
import { computed, onMounted, ref } from 'vue'
import { getPrizesApi, getPrizeApi, type Prize } from './utils'
const prizes = ref<Prize[]>([])
const winningPrize = ref<Prize>()
const dialogVisible = ref(false)
const { degrees, sectorDegrees, play, stop, pause } = useLuckyWheel({
count: computed(() => prizes.value.length),
complete: (index) => {
winningPrize.value = prizes.value[index]
dialogVisible.value = true
},
})
const repeatingConicGradient = computed(() => {
const angle = sectorDegrees.value
return (
`repeating-conic-gradient(` +
`var(--sector-bg) ${-angle / 2}deg, var(--sector-bg) ${angle / 2}deg,` +
`transparent ${angle / 2 + 0.5}deg, transparent ${angle / 2 + angle}deg,` +
`var(--sector-bg) ${angle / 2 + angle + 0.5}deg)`
)
})
const onPlay = () => {
play()
getPrizeApi(8)
.then((prize) => {
stop(prizes.value.findIndex((item) => item.id === prize.id))
})
.catch(() => pause())
}
onMounted(() => {
getPrizesApi(8).then((res) => {
prizes.value = res
})
})
</script>
<style lang="scss" scoped>
.disc {
position: relative;
width: 330px;
height: 330px;
margin: 0 auto;
border: 5px solid #ffddcf;
border-radius: 50%;
overflow: hidden;
--sector-bg: #fff0cb;
}
.sectors {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #fffbef;
}
.sector {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.sector-half {
width: 100%;
height: 50%;
display: flex;
flex-direction: column;
align-items: center;
}
.prize-icon {
margin-top: 10px;
font-size: 36px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 10px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
width: 80px;
height: 80px;
border: 4px solid #fff;
border-radius: 50%;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.play-btn-arrow {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%) scaleX(0.4);
&::before {
content: '';
display: flex;
width: 30px;
height: 30px;
background-color: #f02020;
transform: rotate(45deg);
}
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 0;
}
.dialog-prize-icon {
font-size: 80px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
line-height: 16px;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>vue
<template>
<div class="disc">
<div
class="sectors"
:style="{
backgroundImage: repeatingConicGradient,
transform: `rotate(${degrees}deg)`,
}"
>
<div
v-for="(prize, index) in prizes"
:key="prize.id"
class="sector"
:class="{ 'sector-event': index % 2 === 0 }"
:style="{
transform: `rotate(${-index * sectorDegrees}deg)`,
}"
>
<div class="sector-half">
<div class="prize-icon">
<span :class="['cake', `cake-${prize.icon}`]"></span>
</div>
<div class="prize-name">{{ prize.name }}</div>
</div>
</div>
</div>
<div class="play-btn" @click="onPlay">
<span>点我抽奖</span>
<div class="play-btn-arrow"></div>
</div>
</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize" class="dialog-prize">
<div class="dialog-prize-icon">
<span :class="['cake', `cake-${winningPrize.icon}`]"></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">“{{ winningPrize.name }}”</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="js">
import { useLuckyWheel } from 'sard'
import { computed, onMounted, ref } from 'vue'
import { getPrizesApi, getPrizeApi } from './utils'
const prizes = ref([])
const winningPrize = ref()
const dialogVisible = ref(false)
const { degrees, sectorDegrees, play, stop, pause } = useLuckyWheel({
count: computed(() => prizes.value.length),
complete: (index) => {
winningPrize.value = prizes.value[index]
dialogVisible.value = true
},
})
const repeatingConicGradient = computed(() => {
const angle = sectorDegrees.value
return (
`repeating-conic-gradient(` +
`var(--sector-bg) ${-angle / 2}deg, var(--sector-bg) ${angle / 2}deg,` +
`transparent ${angle / 2 + 0.5}deg, transparent ${angle / 2 + angle}deg,` +
`var(--sector-bg) ${angle / 2 + angle + 0.5}deg)`
)
})
const onPlay = () => {
play()
getPrizeApi(8)
.then((prize) => {
stop(prizes.value.findIndex((item) => item.id === prize.id))
})
.catch(() => pause())
}
onMounted(() => {
getPrizesApi(8).then((res) => {
prizes.value = res
})
})
</script>
<style lang="scss" scoped>
.disc {
position: relative;
width: 330px;
height: 330px;
margin: 0 auto;
border: 5px solid #ffddcf;
border-radius: 50%;
overflow: hidden;
--sector-bg: #fff0cb;
}
.sectors {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #fffbef;
}
.sector {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.sector-half {
width: 100%;
height: 50%;
display: flex;
flex-direction: column;
align-items: center;
}
.prize-icon {
margin-top: 10px;
font-size: 36px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 10px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
width: 80px;
height: 80px;
border: 4px solid #fff;
border-radius: 50%;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.play-btn-arrow {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%) scaleX(0.4);
&::before {
content: '';
display: flex;
width: 30px;
height: 30px;
background-color: #f02020;
transform: rotate(45deg);
}
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 0;
}
.dialog-prize-icon {
font-size: 80px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
line-height: 16px;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>老虎机
老虎机可以一次性抽取多个奖品。
columns 选项用于指定列数以及每列的奖品数量;
offset 计算属性保存着每一列的百分比偏移量,每一列奖品容器的高度需和奖品高度一致;
为了实现首位相接的效果,还需在渲染的奖品列表的末尾加上第一个奖品。
vue
<template>
<div class="reels">
<div v-for="(translateY, index) in offset" :key="index" class="reel-box">
<div class="reel" :style="{ transform: `translateY(${translateY}%)` }">
<div v-for="(prize, index) in renderedPrizes" :key="index" class="prize-item">
<div class="prize-icon">
<span :class="['cake', `cake-${prize.icon}`]"></span>
</div>
<div class="prize-name">{{ prize.name }}</div>
</div>
</div>
</div>
</div>
<div class="play-btn" @click="onPlay()">点我抽奖</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize.length > 0" class="dialog-prize">
<div class="dialog-prize-icon">
<span
v-for="(prize, index) in winningPrize"
:key="index"
:class="['cake', `cake-${prize.icon}`]"
></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">
“{{ winningPrize.map((prize) => prize.name).join('、') }}”
</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="ts">
import { useSlotMachine } from 'sard'
import { computed, onMounted, ref } from 'vue'
import { getPrizesApi, getMultiPrizeApi, type Prize } from './utils'
const prizes = ref<Prize[]>([])
const renderedPrizes = computed(() => {
return prizes.value.length === 0 ? [] : [...prizes.value, prizes.value[0]]
})
const winningPrize = ref<Prize[]>([])
const dialogVisible = ref(false)
const columns = ref<number[]>([])
const { play, stop, pause, offset } = useSlotMachine({
columns,
complete: (indexes) => {
winningPrize.value = indexes.map((index) => prizes.value[index])
dialogVisible.value = true
},
})
const onPlay = () => {
play()
getMultiPrizeApi([8, 8, 8])
.then((multiPrizes) => {
stop(multiPrizes.map((prize) => prizes.value.findIndex((item) => item.id === prize.id)))
})
.catch(() => pause())
}
onMounted(() => {
getPrizesApi(8).then((res) => {
columns.value = [8, 8, 8]
prizes.value = res
})
})
</script>
<style scoped lang="scss">
.reels {
display: flex;
height: 120px;
gap: 10px;
padding: 0 10px;
border: 5px solid #ffddcf;
border-radius: 16px;
background-color: #fffbef;
}
.reel-box {
flex: 1;
height: 100%;
overflow: hidden;
background-color: #fff0cb;
}
.reel {
display: flex;
flex-direction: column-reverse;
height: 100%;
}
.prize-item {
flex: none;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.prize-icon {
font-size: 36px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 10px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
margin-top: 10px;
border-radius: 12px;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 16px;
}
.dialog-prize-icon {
display: flex;
gap: 16px;
font-size: 48px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
text-align: center;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>vue
<template>
<div class="reels">
<div v-for="(translateY, index) in offset" :key="index" class="reel-box">
<div class="reel" :style="{ transform: `translateY(${translateY}%)` }">
<div v-for="(prize, index) in renderedPrizes" :key="index" class="prize-item">
<div class="prize-icon">
<span :class="['cake', `cake-${prize.icon}`]"></span>
</div>
<div class="prize-name">{{ prize.name }}</div>
</div>
</div>
</div>
</div>
<div class="play-btn" @click="onPlay()">点我抽奖</div>
<s-dialog v-model:visible="dialogVisible" :show-cancel="false" confirm-text="收下">
<div v-if="winningPrize.length > 0" class="dialog-prize">
<div class="dialog-prize-icon">
<span
v-for="(prize, index) in winningPrize"
:key="index"
:class="['cake', `cake-${prize.icon}`]"
></span>
</div>
<div class="dialog-prize-title">
<span>恭喜你抽中</span>
<span class="dialog-prize-name">
“{{ winningPrize.map((prize) => prize.name).join('、') }}”
</span>
</div>
</div>
</s-dialog>
</template>
<script setup lang="js">
import { useSlotMachine } from 'sard'
import { computed, onMounted, ref } from 'vue'
import { getPrizesApi, getMultiPrizeApi } from './utils'
const prizes = ref([])
const renderedPrizes = computed(() => {
return prizes.value.length === 0 ? [] : [...prizes.value, prizes.value[0]]
})
const winningPrize = ref([])
const dialogVisible = ref(false)
const columns = ref([])
const { play, stop, pause, offset } = useSlotMachine({
columns,
complete: (indexes) => {
winningPrize.value = indexes.map((index) => prizes.value[index])
dialogVisible.value = true
},
})
const onPlay = () => {
play()
getMultiPrizeApi([8, 8, 8])
.then((multiPrizes) => {
stop(multiPrizes.map((prize) => prizes.value.findIndex((item) => item.id === prize.id)))
})
.catch(() => pause())
}
onMounted(() => {
getPrizesApi(8).then((res) => {
columns.value = [8, 8, 8]
prizes.value = res
})
})
</script>
<style scoped lang="scss">
.reels {
display: flex;
height: 120px;
gap: 10px;
padding: 0 10px;
border: 5px solid #ffddcf;
border-radius: 16px;
background-color: #fffbef;
}
.reel-box {
flex: 1;
height: 100%;
overflow: hidden;
background-color: #fff0cb;
}
.reel {
display: flex;
flex-direction: column-reverse;
height: 100%;
}
.prize-item {
flex: none;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.prize-icon {
font-size: 36px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.prize-name {
margin-top: 10px;
font-weight: 500;
font-size: 11px;
color: #eb7e50;
line-height: 16px;
}
.play-btn {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
margin-top: 10px;
border-radius: 12px;
font-weight: 500;
color: #fff;
background-color: #f02020;
box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dialog-prize {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 30px 16px;
}
.dialog-prize-icon {
display: flex;
gap: 16px;
font-size: 48px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dialog-prize-title {
margin-top: 16px;
font-size: 16px;
text-align: center;
}
.dialog-prize-name {
color: #eb7e50;
font-weight: 600;
}
</style>API
useLuckyGrid
ts
function useLuckyGrid(options?: UseLuckyGridOptions): UseLuckyGridReturnUseLuckyGridOptions
| 属性 | 描述 | 类型 | 默认值 |
|---|---|---|---|
| row | 宫格行数 | number | Ref<number> | 3 |
| column | 宫格列数 | number | Ref<number> | 3 |
| minSpeed | 最小加速度 | number | Ref<number> | 0.1 |
| maxSpeed | 最大加速度 | number | Ref<number> | 0.4 |
| accelTime | 加速时间,单位毫秒 | number | Ref<number> | 2500 |
| decelTime | 减速时间,单位毫秒 | number | Ref<number> | 2500 |
| easeIn | 加速缓动公式 | (progress: number) => number | (k) => k \* k |
| easeOut | 减速缓动公式 | (progress: number) => number | (k) => k \* (2 - k) |
| startDelay | 加速运动前的等待时间,单位毫秒 | number | Ref<number> | 0 |
| endDelay | 减速运动后的等待时间,单位毫秒 | number | Ref<number> | 500 |
| complete | 完成抽奖动画后的回调 | (index: number) => void | - |
UseLuckyGridReturn
| 属性 | 描述 | 类型 |
|---|---|---|
| play | 调用后开始抽奖 | () => void |
| stop | 调用后开始减速动画 | (index?: number) => void |
| pause | 调用后暂停动画 | () => void |
| reset | 调用后重置动画 | () => void |
| playing | 用于判断当前是否正在动画 | ComputedRef<boolean> |
| activeIndex | 用于判断当前活动的下标 | ComputedRef<number | undefined> |
| grids | 用于渲染成宫格,并根据下标判断奖品和非奖品位置 | ComputedRef<number[]> |
| centerSize | 用于渲染中间非奖品格子的行列数 | ComputedRef\<{ row: number; column: number; }> |
useLuckyWheel
ts
function useLuckyWheel(options?: UseLuckyWheelOptions): UseLuckyWheelReturnUseLuckyWheelOptions
| 属性 | 描述 | 类型 | 默认值 |
|---|---|---|---|
| count | 奖品个数 | number | Ref<number> | 8 |
| minSpeed | 最小加速度 | number | Ref<number> | 0.01 |
| maxSpeed | 最大加速度 | number | Ref<number> | 0.4 |
| accelTime | 加速时间,单位毫秒 | number | Ref<number> | 2500 |
| decelTime | 减速时间,单位毫秒 | number | Ref<number> | 2500 |
| easeIn | 加速缓动公式 | (progress: number) => number | (k) => k \* k |
| easeOut | 减速缓动公式 | (progress: number) => number | (k) => k \* (2 - k) |
| startDelay | 加速运动前的等待时间,单位毫秒 | number | Ref<number> | 0 |
| endDelay | 减速运动后的等待时间,单位毫秒 | number | Ref<number> | 300 |
| complete | 完成抽奖动画后的回调 | (index: number) => void | - |
UseLuckyWheelReturn
| 属性 | 描述 | 类型 |
|---|---|---|
| play | 调用后开始抽奖 | () => void |
| stop | 调用后开始减速动画 | (index?: number) => void |
| pause | 调用后暂停动画 | () => void |
| reset | 调用后重置动画 | () => void |
| playing | 用于判断当前是否正在动画 | ComputedRef<boolean> |
| sectorDegrees | 每个扇形奖品所占的角度,单位 deg | ComputedRef<number> |
| degrees | 当前转盘渲染的角度,单位 deg | ComputedRef<number> |
useSlotMachine
ts
function useSlotMachine(options: UseSlotMachineOptions): UseSlotMachineReturnUseSlotMachineOptions
| 属性 | 描述 | 类型 | 默认值 |
|---|---|---|---|
| columns | 奖品个数 | number[] | unknown[][] | Ref<number[] | unknown[][]> | [] |
| staggerDelay | 列间交错延迟时间,单位毫秒 | number | Ref<number> | 600 |
| minSpeed | 最小加速度 | number | Ref<number> | 0.01 |
| maxSpeed | 最大加速度 | number | Ref<number> | 0.3 |
| accelTime | 加速时间,单位毫秒 | number | Ref<number> | 2500 |
| decelTime | 减速时间,单位毫秒 | number | Ref<number> | 2500 |
| easeIn | 加速缓动公式 | (progress: number) => number | (k) => k \* k |
| easeOut | 减速缓动公式 | (progress: number) => number | (k) => k \* (2 - k) |
| startDelay | 加速运动前的等待时间,单位毫秒 | number | Ref<number> | 0 |
| endDelay | 减速运动后的等待时间,单位毫秒 | number | Ref<number> | 300 |
| complete | 完成抽奖动画后的回调 | (index: number[]) => void | - |
UseSlotMachineReturn
| 属性 | 描述 | 类型 |
|---|---|---|
| play | 调用后开始抽奖 | () => void |
| stop | 调用后开始减速动画 | (index?: number[]) => void |
| pause | 调用后暂停动画 | () => void |
| reset | 调用后重置动画 | () => void |
| playing | 用于判断当前是否正在动画 | ComputedRef<boolean> |
| offset | 每一列的当前偏移量 | ComputedRef<number[]> |