Skip to content

易碎包裝器 wrapper

最近玩《咚奇剛蕉力全開》玩得好快樂!♪( ◜ω◝و(و

真不愧是《瑪利歐奧德賽》的開發團隊,不管是遊玩方式還是場地設計都超讚

不過不知道為什麼這款遊戲竟然是我人生第二名 3D 暈的遊戲,沒辦法沒日沒夜地玩,太傷心了 ・゚・(つд`゚)・゚・

遊戲中的按鈕點擊後會產生破裂效果,讓我決定在網頁上致敬一個效果類似的元件!(・∀・)9

第一暈的遊戲是啥?

第一名是《What Remains of Edith Finch》,暈到連第一章都過不了。(。-`ω´-)

有大大知道如何防止或減緩 3D 暈,拜託告訴我一下,萬分感謝!(´,,•ω•,,)

技術關鍵字

名稱 描述
PixiJS高性能 2D 渲染引擎,適合製作遊戲、互動動畫等
Anime.js輕量級 JavaScript 動畫函式庫
JS 動畫基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等
向量計算處理方向、加速度、速度等等數學運算
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊
Delaunay一種三角剖分演算法,常用於地形生成、網格劃分、計算最近鄰等

使用範例

已知限制

實際效果由疊在 DOM 上方的 canvas 展現,所以 DOM 上的互動內容會被擋住。例如:反白、插入游標等等

基本用法

點擊會讓目標裂開,點越多下裂越多,萬物皆可碎 ᕕ( ゚ ∀。)ᕗ

(滑鼠右鍵或觸控長按可回復)

玻璃
無定形固體,易發生脆性破壞。
研究指出,此特性在人類特定職位或身分亦可見,俗稱「玻璃心」,其臨界應力約等於一句實話。
實務上,對該材質宜採退火式溝通:先以「可能我理解錯」進行預熱,降低熱應力梯度,再逐步投放事實,以避免瞬間破壞。
需留意責任歸因位移之副作用:真的變成你有錯。
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col items-center justify-center gap-4 md:flex-row">
    <wrapper-brittle
      :ref="cardRefList.set"
      v-slot="{ restore }"
      class="w-1/2"
    >
      <div
        class="cursor-pointer border"
        @contextmenu.prevent="restore()"
      >
        <img
          src="/low/profile-2.webp"
          alt=""
          class="pointer-events-none"
        >
      </div>
    </wrapper-brittle>

    <wrapper-brittle
      :ref="cardRefList.set"
      v-slot="{ restore }"
    >
      <div
        class="card flex flex-col cursor-pointer gap-2 border rounded p-4 text-sm"
        @contextmenu.prevent="restore()"
      >
        <div class="text-xl font-bold">
          玻璃
        </div>

        <div>
          無定形固體,易發生脆性破壞。
        </div>

        <div>
          研究指出,此特性在人類特定職位或身分亦可見,俗稱「玻璃心」,其臨界應力約等於一句實話。
        </div>

        <div>
          實務上,對該材質宜採退火式溝通:先以「可能我理解錯」進行預熱,降低熱應力梯度,再逐步投放事實,以避免瞬間破壞。
        </div>

        <div>
          需留意責任歸因位移之副作用:真的變成你有錯。
        </div>
      </div>
    </wrapper-brittle>
  </div>
</template>

<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import { throttle } from 'lodash-es'
import { useData } from 'vitepress'
import { onMounted, watch } from 'vue'
import WrapperBrittle from '../wrapper-brittle.vue'

const data = useData()

const cardRefList = useTemplateRefsList<
  InstanceType<typeof WrapperBrittle>
>()

const refreshContent = throttle(() => {
  cardRefList.value?.forEach((cardRef) => {
    cardRef.refresh()
  })
}, 100, {
  leading: true,
})

watch(() => data.isDark.value, async () => {
  refreshContent()
})

/** FIX: 水合會導致初始化異常,目前先用延遲更新解決 */
onMounted(() => {
  setTimeout(() => {
    refreshContent()
  }, 1000)
})
</script>

<style lang="sass">
.card
  background: light-dark(#EEE, #333)
</style>

表單範例

沒填完按送出,按鈕會碎給你看。 ( ´థ౪థ)

送出表單
查看範例原始碼
vue
<template>
  <div class="relative w-full flex justify-center border border-gray-200 rounded-xl p-6">
    <div class="max-w-[20rem] flex flex-col gap-4">
      <base-input
        v-model="form.username"
        :label="t('帳號 *')"
        class="w-full"
      />

      <base-input
        v-model="form.password"
        type="password"
        :label="t('密碼 *')"
        class="w-full"
      />

      <div class="mt-3 flex justify-center">
        <wrapper-brittle
          ref="cardRef"
          :enabled="disabled"
          :thresh-decrease="0.8"
        >
          <div
            class="submit-btn cursor-pointer select-none border rounded-lg p-4 px-6"
            @click="handleSubmit"
          >
            {{ t('送出表單') }}
          </div>
        </wrapper-brittle>
      </div>
    </div>

    <transition name="opacity">
      <div
        v-if="isSubmitted"
        class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
        @click="reset"
      >
        <span class="text-xl tracking-wide">
          {{ t('表單已送出!(*´∀`)~♥') }}
        </span>

        <span class="cursor-pointer text-xs">
          {{ t('點一下再來一次') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { throttle } from 'lodash-es'
import { useData } from 'vitepress'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseInput from '../../base-input.vue'
import WrapperBrittle from '../wrapper-brittle.vue'

const { t } = useI18n()

const data = useData()

const cardRef = useTemplateRef('cardRef')

const refreshContent = throttle(() => {
  cardRef.value?.refresh()
}, 100, {
  leading: true,
})
watch(() => data.isDark.value, async () => {
  refreshContent()
})

const form = ref({
  username: '',
  password: '',
})
const disabled = computed(() => {
  return form.value.username === '' || form.value.password === ''
})
whenever(() => !disabled.value, () => {
  cardRef.value?.restore()
})

const isSubmitted = ref(false)
function handleSubmit() {
  if (disabled.value) {
    return
  }
  isSubmitted.value = true
}

function reset() {
  isSubmitted.value = false

  form.value = {
    username: '',
    password: '',
  }
}
</script>

<style lang="sass" scoped>
.submit-btn
  border-color: light-dark(#222, #EEE)
  background-color: light-dark(#FAFAFA, #111)
  color: light-dark(#111, #EEE)

.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
  opacity: 0 !important
</style>

強化停用

強調停用狀態,寧為玉碎,不為修改!(ง •̀_•́)ง

鱈魚:是哪個小壞蛋擅自給我取這鬼名字!ლ(´口`ლ)

查看範例原始碼
vue
<template>
  <div class="relative w-full flex justify-center border border-gray-200 rounded-xl p-6 py-10">
    <div class="max-w-[20rem] flex flex-col gap-2">
      <label> 帳號 </label>
      <input
        v-model="account.username"
        class="border rounded border-solid p-2 px-4"
      >

      <label class="mt-4"> 名稱 </label>
      <wrapper-brittle
        ref="cardRef"
        :thresh-decrease="1"
        @count="restore"
      >
        <input
          :value="account.name"
          class="cursor-not-allowed border rounded border-dashed bg-gray-100 p-2 px-4"
          disabled
        >
      </wrapper-brittle>
    </div>
  </div>
</template>

<script setup lang="ts">
import { promiseTimeout } from '@vueuse/core'
import { throttle } from 'lodash-es'
import { useData } from 'vitepress'
import { ref, useTemplateRef, watch } from 'vue'
import WrapperBrittle from '../wrapper-brittle.vue'

const data = useData()

const cardRef = useTemplateRef('cardRef')

const refreshContent = throttle(() => {
  cardRef.value?.refresh()
}, 100, {
  leading: true,
})
watch(() => data.isDark.value, async () => {
  refreshContent()
})

const account = ref({
  username: 'cod',
  name: '煞氣的肥魚',
})

const isBrittle = ref(false)
async function restore() {
  isBrittle.value = true
  await promiseTimeout(400)
  cardRef.value?.restore()
  isBrittle.value = false
}
</script>

原理

將 DOM 轉成圖片後,使用 Delaunay 演算法分割成多個三角形,接著將相鄰的三角形合併成多邊形「塊」,每個塊由多個三角形組成,並記錄塊與塊之間的接縫。

每個三角形由 PixiJS + WebGPU 負責渲染,性能極佳。( •̀ ω •́ )✧

當使用者點擊畫布時,根據點擊位置計算每個接縫的分數,分數高於門檻的接縫會被「斷裂」,使塊分組。

每個分組(碎片)會根據其中心位置,往遠離點擊點的方向位移,產生破裂效果。

碎片的移動與復原都透過 Anime.js 平滑過渡,提升視覺效果。

詳細實現可以看看原始碼,真的是說起來簡單,實作起來有夠複雜。...(›´ω`‹ )

原始碼

API

Props

interface Props {
  enabled?: boolean;
  /** 單位 px,越小切得越碎 */
  splitStep?: number;
  /** 單位 px,越大碎片越不規則 */
  splitJitter?: number;
  /** 每次點擊各碎片移動量,單位 px */
  moveDistancePerClick?: number;
  /** 崩裂門檻,數值越大越容易裂開,越小則需要點越多下才會裂開
   * @default 0.6
   */
  threshDecrease?: number;
}

Emits

interface Emits {
  count: [value: number];
}

Methods

interface Expose {
  clickCount: ComputedRef<number>;
  /** 復原 */
  restore: (duration?: number) => void;
  /** 強制更新 DOM 圖片 */
  refresh: () => void;
}

Slots

interface Slots {
  default?: (props: {
    restore: (duration?: number) => void;
  }) => unknown;
}

v0.47.1