Skip to content

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;
}

v0.54.2