
每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
元素包裝器
wrapper包起來的內容會結冰!燒毀!(「・ω・)「
| 名稱 | 描述 |
|---|---|
| Babylon.js | 3D 引擎 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| SDF (Signed Distance Field) | 有號距離場,用數學函式描述形狀輪廓,常用於 Shader 中繪製平滑圖形 |
| Stable Fluids | Jos Stam 提出的即時流體解法,平流、外力、壓力投影輪流跑,數值再大也不會爆掉 |
| Noise | 比 Math.random 更自然隨機效果,常用於地形、雲朵、材質等 |
| Voronoi | 細胞噪聲,把平面切成一格格不規則細胞,冰塊裡的裂紋就是這樣來的 |
| Fresnel | 視線越貼近表面反射越強的現象,冰塊、玻璃邊緣發亮、正面透明就是它 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
| Bloom | 泛光,把畫面裡超亮的部分抽出來模糊後加回去,發光體周圍才會暈開 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
把 element 設成 fire 或 ice 就會套上效果。

鱈魚
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。(´;ω;`)
<template> <div class="w-full flex flex-col items-center gap-12 py-10"> <div class="flex flex-col items-center gap-3"> <div class="flex flex-wrap justify-center gap-3"> <base-btn v-for="option in optionList" :key="option.value" :label="option.label" :class="{ 'option--active': element === option.value }" @click="element = option.value" /> </div>
<!-- 測試用:複製整頁共用畫布的效能報告,方便回報 --> <base-btn v-if="perfReportVisible" :label="copied ? t('copied') : t('copyPerfReport')" class="text-xs opacity-70" @click="copyPerfReport" /> </div>
<wrapper-elemental :ref="wrapperRefList.set" :element="element" > <img src="/low/profile.webp" alt="" class="w-40 border-[0.25rem] rounded-full object-cover" > </wrapper-elemental>
<wrapper-elemental :ref="wrapperRefList.set" :element="element" > <div class="card border rounded-lg p-6"> <div class="text-2xl font-bold"> {{ t('codfish') }} </div> <div class="mt-2 max-w-[17rem]"> {{ t('codfishDescription') }} </div> </div> </wrapper-elemental> </div></template>
<script setup lang="ts">import type { ElementalType } from '../type'import { useClipboard, useTemplateRefsList } from '@vueuse/core'import { computed, ref, watch } from 'vue'import { useI18n } from 'vue-i18n'import { useSiteDark } from '../../../composables/use-site-dark'import BaseBtn from '../../base-btn.vue'import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()const isDark = useSiteDark()
const element = ref<ElementalType>('fire')
const optionList = computed(() => [ { value: 'fire' as const, label: t('fire') }, { value: 'ice' as const, label: t('ice') }, { value: 'none' as const, label: t('none') },])
const wrapperRefList = useTemplateRefsList< InstanceType<typeof WrapperElemental>>()
/** 深色模式切換後配色會變,需重新擷取內容 */watch(() => isDark.value, () => { wrapperRefList.value.forEach((wrapperRef) => { wrapperRef.refresh() })})
/** 效能報告按鈕平時隱藏,要量測時改成 true */const perfReportVisible = false
const { copy, copied } = useClipboard({ legacy: true })
/** 所有元素共用一張畫布,任一個元素拿到的都是整頁的報告 */function copyPerfReport() { const report = wrapperRefList.value .map((wrapperRef) => wrapperRef.getPerfReport()) .find((value) => value !== undefined) copy(`時間:${new Date().toISOString()}\n\n${report ?? t('noPerfReport')}`)}</script>
<style scoped lang="sass">.card background: light-dark(#EEE, #333)
.option--active background-color: light-dark(#DDD, #555)</style>沒填完,按鈕會凍結。(´,,•ω•,,)
<template> <div class="relative w-full flex justify-center py-24"> <div class="max-w-[20rem] flex flex-col gap-4"> <base-input v-model="form.nickname" type="text" autocomplete="off" :label="t('暱稱 *')" :placeholder="t('至少 {count} 個字', { count: MIN_NICKNAME_LENGTH })" class="w-full" />
<div class="mt-3 flex justify-center"> <wrapper-elemental ref="wrapperRef" :element="unfinished ? 'ice' : 'none'" :transition-duration="800" > <base-btn :label="t('送出')" @click="handleSubmit" /> </wrapper-elemental> </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 { computed, ref, useTemplateRef, watch } from 'vue'import { useI18n } from 'vue-i18n'import { useSiteDark } from '../../../composables/use-site-dark'import BaseBtn from '../../base-btn.vue'import BaseInput from '../../base-input.vue'import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()const isDark = useSiteDark()
const MIN_NICKNAME_LENGTH = 2
const wrapperRef = useTemplateRef<InstanceType<typeof WrapperElemental>>('wrapperRef')
/** 深色模式切換後配色會變,需重新擷取內容 */watch(() => isDark.value, () => { wrapperRef.value?.refresh()})
const form = ref({ nickname: '',})
/** 暱稱字數不足,按鈕就凍在冰塊裡 */const unfinished = computed(() => form.value.nickname.length < MIN_NICKNAME_LENGTH)
const isSubmitted = ref(false)function handleSubmit() { // 冰塊只是畫面,底下的按鈕還點得到,送出前要再檢查一次 if (unfinished.value) { return } isSubmitted.value = true}
function reset() { isSubmitted.value = false
form.value = { nickname: '', }}</script>
<style lang="sass" scoped>.opacity-enter-active, .opacity-leave-active transition-duration: 0.4s.opacity-enter-from, .opacity-leave-to opacity: 0 !important</style>客戶:「商品要營造火熱的感覺。」
鱈魚:「好。」
熱門專案水中筆電
防水 100 公尺,鍵盤專為魚鰭設計,泡在水裡也能寫程式。
NT$1,200目標 NT$1,000,000
0.12%達成率
3贊助人數
2剩餘天數
<template> <div class="w-full flex justify-center px-4 pb-10 pt-32"> <div class="relative max-w-[20rem] w-full"> <!-- 外框墊在卡片底下,只有邊線是燃料,火沿著卡片外圍大肆燃燒 --> <div v-if="cardSize.height > 0" class="pointer-events-none absolute left-0 top-0" > <wrapper-elemental ref="frameRef" :element="fireElement" :intensity="1.1" > <div class="frame" :style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }" /> </wrapper-elemental> </div>
<!-- 卡片本身也燒,火勢壓低,只冒小火,內容才看得清楚 --> <wrapper-elemental ref="cardWrapperRef" :element="fireElement" :intensity="0.2" > <div ref="cardRef" class="project flex flex-col items-center rounded-xl px-5 py-7" > <span class="badge rounded-full px-2.5 py-0.5 text-xs font-bold"> {{ t('badge') }} </span> <span class="title mt-2 text-center text-4xl font-black leading-tight"> {{ t('title') }} </span> <div class="description mt-2 max-w-60 text-center text-xs leading-relaxed"> {{ t('description') }} </div>
<div class="mt-6 max-w-60 w-full flex flex-col items-center"> <span class="raised text-3xl font-bold"> NT${{ numberFormat.format(raised) }} </span> <span class="muted mt-0.5 text-xs"> {{ t('goal') }} NT${{ numberFormat.format(GOAL) }} </span>
<div class="track mt-3 h-1.5 w-full overflow-hidden rounded-full" role="progressbar" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="rate" :aria-label="t('rate')" > <div class="fill h-full rounded-full" :style="{ width: `max(${rate}%, 0.25rem)` }" /> </div>
<div class="grid grid-cols-3 mt-3 w-full text-center"> <div v-for="stat in statList" :key="stat.label" class="flex flex-col" > <span class="stat-value text-base font-bold"> {{ stat.value }} </span> <span class="muted text-xs"> {{ stat.label }} </span> </div> </div> </div>
<button type="button" class="back-btn mt-6 rounded-full px-8 py-2 text-base font-bold" @click="back" > {{ t('back') }} </button> </div> </wrapper-elemental> </div> </div></template>
<script setup lang="ts">import type { ElementalType } from '../type'import { useElementSize, useElementVisibility, useMediaQuery, useMouseInElement, whenever } from '@vueuse/core'import { computed, reactive, ref, useTemplateRef, watch } from 'vue'import { useI18n } from 'vue-i18n'import { useSiteDark } from '../../../composables/use-site-dark'import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()const isDark = useSiteDark()
/** 目標一百萬、已募一千二,每次贊助一百 */const GOAL = 1_000_000const INITIAL_RAISED = 1200const BACK_AMOUNT = 100const INITIAL_BACKERS = 3const DAYS_LEFT = 2/** 滑鼠離卡片多近(px)就起火 */const IGNITE_DISTANCE = 60
const numberFormat = new Intl.NumberFormat('en-US')
const backCount = ref(0)const raised = computed(() => INITIAL_RAISED + backCount.value * BACK_AMOUNT)const rate = computed(() => (raised.value / GOAL * 100).toFixed(2))
const statList = computed(() => [ { label: t('rate'), value: `${rate.value}%` }, /** 同一個人贊助幾次都只算一位 */ { label: t('backers'), value: INITIAL_BACKERS + (backCount.value > 0 ? 1 : 0) }, { label: t('daysLeft'), value: DAYS_LEFT },])
function back() { backCount.value++}
const cardRef = useTemplateRef('cardRef')
/** * 外框跟著卡片大小。 * * 量到尺寸才掛上外框,掛載時才不會擷取到 0×0 的空白畫面 */const cardSize = reactive(useElementSize(cardRef, undefined, { box: 'border-box' }))
const { elementX, elementY, elementWidth, elementHeight, sourceType } = useMouseInElement(cardRef)
/** 滑鼠離卡片邊緣多遠(px),在卡片上為 0 */const pointerDistance = computed(() => Math.hypot( Math.max(-elementX.value, 0, elementX.value - elementWidth.value), Math.max(-elementY.value, 0, elementY.value - elementHeight.value),))
const isCoarsePointer = useMediaQuery('(hover: none)')const isCardVisible = useElementVisibility(cardRef, { threshold: 0.9 })
/** * 滑鼠一靠近,卡片連同外圍一起燒起來,跟募資成績無關。 * 觸控裝置沒有游標,改成卡片幾乎整張進入畫面時起火 */const fireElement = ref<ElementalType>('none')whenever( () => isCoarsePointer.value ? isCardVisible.value : sourceType.value !== null && pointerDistance.value < IGNITE_DISTANCE, () => { fireElement.value = 'fire' }, { once: true },)
const frameRef = useTemplateRef<InstanceType<typeof WrapperElemental>>('frameRef')const cardWrapperRef = useTemplateRef<InstanceType<typeof WrapperElemental>>('cardWrapperRef')
/** * 贊助後數字會變,卡片快照要重拍,但這裡不必手動 refresh。 * Vue 更新文字是替換文字節點,元件監聽到節點變動會自己重拍; * 再手動叫一次,每按一次就拍兩次,手機上會明顯卡頓。 * * 深色模式切換發生在元件外面,元件看不到,外框與卡片都要手動重新擷取 */watch(() => isDark.value, () => { frameRef.value?.refresh() cardWrapperRef.value?.refresh()})</script>
<style scoped lang="sass">.frame border: 4px solid light-dark(#E5E5E5, #333) // 火往上竄,底邊一燒就會爬進卡片、蓋住按鈕,所以只留上緣與兩側 border-bottom: none // 與卡片的 rounded-xl 一樣大,外框才貼得上 border-radius: 0.75rem
.project border: 1px solid light-dark(#E5E5E5, #333) background: light-dark(#F7F7F7, #262626)
.badge color: #FFF background: #E5484D
.title, .raised, .stat-value color: light-dark(#1F1F1F, #F5F5F5)
.raised, .stat-value font-variant-numeric: tabular-nums
.description, .muted color: light-dark(#666, #AAA)
.track background: light-dark(#E2E2E2, #3A3A3A)
.fill, .back-btn background: #10B981
.back-btn color: #FFF transition: scale 0.1s &:active scale: 0.97</style>整頁共用一張畫布
Section titled “整頁共用一張畫布”內容先用 snapdom 拍成貼圖,再算出一張有號距離場,火與冰塊都靠它知道輪廓在哪。內容重拍時若輪廓沒變(例如只是文字換了),距離場直接沿用,不重算。
整頁的元素共用一張鋪滿視窗的畫布、一份流體模擬與一套泛光,空氣固定在畫面上。捲動時是元素在空氣裡移動,已經竄起的火與冒出的霧留在原地,像被風吹向後方。
熱從輪廓冒出、受浮力往上竄,由 GPU 流體模擬出火焰的形狀,再以體積光線步進沿深度累加 3D 噪聲,畫出有前後層次的火。
真實火焰會週期性地一口一口往上吐(puffing),這裡以營火尺度約 2 Hz 為準。火會遮住內容、讓內容被熱浪扭曲,看起來才在內容前方。
內容先逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊;解除時直接碎成 Voronoi 碎片飛散。
冰面旁的冷空氣變重往下流,霧在冰的底部冒得最多,隨流體往下飄、逐漸散開。
interface Props { /** * 目前附加的元素屬性。 * * `fire` 從底部點燃,GPU 流體模擬的火焰沿著輪廓往上竄,內容受熱變亮,指標掃過會把火吹歪; * `ice` 讓內容逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊。 * 切回 `none` 時火會先熄滅;冰若已凝固就直接碎開,還沒凝固則融化,結束後才還原成原本的 DOM。 * * @default 'none' */ element?: ElementalType; /** * 特效強度,影響火勢、粒子數量、受熱變亮的暖色與冰殼厚度。 * * 1 為預設外觀,2 已相當誇張。強度低於 1 時,受熱變亮的暖色與光暈跟著變淡; * 高於 1 則維持原樣,內容才不會過曝。 * * @default 1 */ intensity?: number; /** * 點燃、熄滅、結凍、融化各自的過渡時間(ms)。 * * @default 1500 */ transitionDuration?: number;}interface Emits { /** 舞台完成初始化、開始繪製時觸發 */ ready: []; /** 過渡結束時觸發,帶著當時的元素屬性;收到 `none` 代表已完全還原 */ settled: [element: ElementalType];}Methods
Section titled “Methods”interface Expose { /** 重新擷取內容圖片,內容或主題變更後呼叫 */ refresh: () => Promise<void>; /** * 測試用:取得效能報告文字,包含裝置、共用畫布、流體規格、頁面上每個元素的狀態與最近 300 幀的耗時。 * 效果未啟動時回傳 undefined */ getPerfReport: () => string | undefined;}interface Slots { /** 要附加元素效果的內容 */ default?: () => unknown;}