OSU 打擊鈕 button
如同 OSU 遊戲的打擊鈕,看準時機點擊按鈕吧!(ゝ∀・)b
至於甚麼是 OSU 遊戲,可以來這裡看看。
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
使用範例
基本用法
觸碰後會出現接近圈圈,在圈圈大小與原本按鈕大小一致時,給他用力點下去!੭ ˙ᗜ˙ )੭
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="flex justify-center">
<btn-osu-hit label="Submit" />
</div>
</div>
</template>
<script setup lang="ts">
import BtnOsuHit from '../btn-osu-hit.vue'
</script>表單範例
Perfect 判定才會觸發事件,同時縮短反應時間,氣死你同事吧 ᕕ( ゚ ∀。)ᕗ
真的不訂飲料?( ・ิω・ิ)貼心小提醒,您即將損失:
- 一杯好喝的飲料
- 悠閒的午休時光
- 美好的心情
操作已完成 (╥ω╥`)
查看範例原始碼
vue
<template>
<div class="relative flex flex-col gap-3 border border-red-300 rounded-lg bg-red-50 p-6">
<span class="text-xl text-red-800 font-bold">
{{ t('dontOrderDrink') }}
</span>
<span class="mt-2 text-red-700">
{{ t('reminder') }}
</span>
<ul class="list-disc list-inside text-sm text-red-600 !m-0">
<li>{{ t('loss1') }}</li>
<li>{{ t('loss2') }}</li>
<li>{{ t('loss3') }}</li>
</ul>
<div class="flex justify-end gap-4">
<base-btn :label="t('cancelBtn')" />
<btn-osu-hit
:reaction-time="400"
@perfect="handleClick"
>
<base-btn
:label="t('confirmBtn')"
class="text-white active:scale-95 !border-none !bg-red-600"
/>
</btn-osu-hit>
</div>
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/90 duration-500"
:class="done ? ' opacity-100' : 'opacity-0'"
>
<span class="text-2xl text-white font-bold">
{{ t('doneTip') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BtnOsuHit from '../btn-osu-hit.vue'
const { t } = useI18n()
const done = ref(false)
function handleClick() {
done.value = true
setTimeout(() => {
done.value = false
}, 2000)
}
</script>節奏挑戰
來挑戰看看吧!ヾ(◍'౪`◍)ノ゙
照著節奏依序按下按鈕
全 Perfect 判定即可解鎖 9 折優惠券!੭ ˙ᗜ˙ )੭
✨ 領取優惠券
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
<div class="flex flex-col gap-1 text-center text-sm">
<div>
{{ t('tip') }}
</div>
<div>
{{ t('tip2') }}
</div>
</div>
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<btn-osu-hit
v-for="item, i in btnList"
:key="i"
:ref="(ref: any) => item.ref = ref"
v-model:hit-result="item.hitResult"
v-model:is-start="item.isStart"
:label="item.label"
class="duration-500"
:class="{
'opacity-10 pointer-events-none': !item.isStart && i !== 0,
}"
:enable-enter-start="i === 0"
@start="start(i)"
/>
</div>
<div
class="coupon-btn"
:class="{ '!cursor-not-allowed opacity-20': !allPerfect }"
@click="handleCouponClick"
>
{{ t('couponBtn') }}
</div>
</div>
</template>
<script setup lang="ts">
import type { ComponentProps } from 'vue-component-type-helpers'
import { promiseTimeout } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnOsuHit from '../btn-osu-hit.vue'
type BtnProps = ComponentProps<typeof BtnOsuHit>
const { t } = useI18n()
const btnList = ref<Array<{
label: string;
isStart: boolean;
hitResult: BtnProps['hitResult'];
ref?: InstanceType<typeof BtnOsuHit>;
}>>([
{ label: t('perfect'), isStart: false, hitResult: undefined },
{ label: t('click'), isStart: false, hitResult: undefined },
{ label: t('just'), isStart: false, hitResult: undefined },
{ label: t('unlock'), isStart: false, hitResult: undefined },
{ label: t('coupon'), isStart: false, hitResult: undefined },
])
const anyStarted = computed(
() => btnList.value.some(({ isStart }) => isStart),
)
const allPerfect = computed(
() => !btnList.value.some(({ hitResult }) => hitResult !== 'perfect'),
)
function start(index: number) {
if (index !== 0 || anyStarted.value) {
return
}
btnList.value.forEach(async (item, i) => {
item.hitResult = undefined
if (i === 0) {
return
}
await promiseTimeout(i * 1000)
item.ref?.startBeat()
})
}
function handleCouponClick() {
if (!allPerfect.value) {
return
}
window.open('https://165.npa.gov.tw/', '_blank')
}
</script>
<style scoped>
.coupon-btn {
display: inline-block;
padding: 12px 24px;
background: linear-gradient(135deg, #ff7a00, #ff3d00);
color: #fff;
font-size: 30px;
font-weight: bold;
border-radius: 12px;
text-align: center;
cursor: pointer;
transition: 0.25s;
box-shadow: 0 4px 10px rgba(255, 100, 0, 0.4);
user-select: none;
}
.coupon-btn:hover {
transform: translateY(-3px);
box-shadow: 0 6px 14px rgba(255, 100, 0, 0.5);
}
.coupon-btn:active {
transform: translateY(0);
box-shadow: 0 3px 8px rgba(255, 100, 0, 0.35);
}
</style>連擊解鎖
使用 slot 可以調整判定結果顯示內容
客訴回饋單
若您有任何疑慮,請在此填寫您的意見,我們將會在第一時間處理您的問題。
連擊「解鎖」按鈕即可解鎖客訴回饋單
20 combo 即可解鎖!您一定可以的!(ゝ∀・)b
查看範例原始碼
vue
<template>
<div class="relative w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
<div
class="flex flex-col gap-3"
:class="{ 'opacity-40 pointer-events-none': !isUnlock }"
>
<div class="text-3xl font-bold">
{{ t('complaintFeedbackForm') }}
</div>
<div class="text-sm text-gray-500">
{{ t('complaintFeedbackFormTip') }}
</div>
<div class="flex justify-center border border-gray-200 rounded-lg p-3">
<textarea
:placeholder="t('inputPlaceholder')"
class="min-h-[30vh] w-full"
/>
</div>
<base-btn
:label="t('submitBtn')"
@click="handleSubmit"
/>
</div>
<div
v-if="!isUnlock"
class="mask absolute inset-0 flex flex-col items-center justify-center gap-5 rounded-xl"
>
<div class="flex flex-col gap-3 text-center">
<div class="text-xl font-bold">
{{ t('unlockTip') }}
</div>
<div class="text-sm opacity-50">
{{ t('unlockTip2', { unlockComboCount: UNLOCK_COMBO_COUNT }) }}
</div>
</div>
<btn-osu-hit
ref="btnRef"
v-model:is-start="isStart"
:enable-enter-start="!isStart"
:label="t('unlockBtn')"
v-on="eventMap"
>
<template #perfect>
<div class="result-text pointer-events-none absolute inset-0 flex-center text-3xl font-bold">
{{ comboCount }} combo!
</div>
</template>
<template #great>
<div class="result-text pointer-events-none absolute inset-0 flex-center text-3xl font-bold">
{{ comboCount }} combo!
</div>
</template>
</btn-osu-hit>
</div>
</div>
</template>
<script setup lang="ts">
import { promiseTimeout, whenever } from '@vueuse/core'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BtnOsuHit from '../btn-osu-hit.vue'
const UNLOCK_COMBO_COUNT = 20
const { t } = useI18n()
const btnRef = useTemplateRef('btnRef')
const comboCount = ref(0)
const isUnlock = ref(false)
whenever(
() => comboCount.value >= UNLOCK_COMBO_COUNT,
() => isUnlock.value = true,
)
const isStart = ref(false)
const eventMap = {
async perfect() {
comboCount.value++
await promiseTimeout(500)
btnRef.value?.startBeat()
},
async great() {
comboCount.value++
await promiseTimeout(500)
btnRef.value?.startBeat()
},
good() {
comboCount.value = 0
},
miss() {
comboCount.value = 0
},
}
function handleSubmit() {
// eslint-disable-next-line no-alert
alert(t('thanks'))
}
</script>
<style scoped lang="sass">
.result-text
animation: result-text 0.6s forwards
-webkit-text-stroke: 5px white
paint-order: stroke fill
text-wrap: nowrap
color: #FFD700
-webkit-text-stroke: 5px #f77816
font-weight: 900
@keyframes result-text
0%
opacity: 0
transform: scale(0.8)
10%, 50%
opacity: 1
transform: scale(1)
100%
opacity: 0
.mask
backdrop-filter: blur(2px)
</style>原理
使用了 Anime.js 來實作動畫並在點擊後依照縮放數值判斷打擊結果。
原始碼
API
Props
interface Props {
label?: string;
/** 反應時間,單位:毫秒 */
reactionTime?: number;
/** pointer enter 是否觸發 start */
enableEnterStart?: boolean;
}Emits
interface Emits {
start: [];
perfect: [];
great: [];
good: [];
miss: [];
}Methods
interface Expose {
/** 開始打擊,會出現 Approach Circle */
startBeat: () => void;
}Slots
interface Slots {
default?: () => unknown;
perfect?: () => unknown;
great?: () => unknown;
good?: () => unknown;
miss?: () => unknown;
}