易碎包裝器 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;
}