吉祥物卡片 card
卡片後面住了一群小動物,時不時探頭、亂跑、跳出來刷存在感。(๑•̀ㅂ•́)و✧
Microsoft Clarity 的網頁中,卡片後面會有動物偷偷探頭,可愛到我當場決定也要做一個。(*´∀`)~♥
模型來自 Kenney 的 Cube Pets,CC0 授權,24 隻方塊小動物通通內建 idle、run、dance 等動畫片段,直接拿來用就好。◝( •ω• )◟
感謝 Kenney!讚嘆 Kenney!੭ ˙ᗜ˙ )੭
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Babylon.js | 3D 引擎 |
| glTF | 3D 模型的通用傳輸格式,.glb 為其二進位打包版本,可同時收錄網格、材質與動畫 |
| Depth Buffer | 深度緩衝,記錄每個像素離鏡頭的距離,渲染時據此判斷誰擋住誰 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
| IntersectionObserver | 偵測元素是否進入或離開視窗 |
使用範例
基本用法
滑鼠移到卡片上,動物會馬上出來打招呼。( ´ ▽ ` )ノ
這張卡片後面住了一群小動物,有空就會出來刷存在感。
查看範例原始碼
<template>
<div class="example-wrap w-full flex flex-col gap-6">
<div class="example-ctrl flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-4">
<base-checkbox
v-model="autoplay"
:label="t('autoplay')"
/>
<base-btn
:label="t('play')"
@click="handlePlay"
/>
<span class="text-sm opacity-70">
{{ statusText }}
</span>
</div>
<select-stepper
v-model="act"
class="max-w-96 w-full"
:label="t('actTitle')"
:options="actOptionList"
:option-label-map="actLabelMap"
/>
<select-stepper
v-model="animal"
class="max-w-96 w-full"
:label="t('animalTitle')"
:options="animalOptionList"
:option-label-map="animalLabelMap"
/>
</div>
<div class="flex justify-center py-16">
<card-mascot
ref="cardRef"
:autoplay
class="mascot-card max-w-full w-80 rounded-2xl p-8 shadow-lg"
@act-start="handleActStart"
@act-end="handleActEnd"
>
<div class="flex flex-col gap-3">
<div class="text-xl font-bold">
{{ t('title') }}
</div>
<p class="leading-relaxed opacity-80">
{{ t('description') }}
</p>
</div>
</card-mascot>
</div>
</div>
</template>
<script setup lang="ts">
import type { ActName, ActPayload, AnimalName } from '../type'
import { camelCase } from 'lodash-es'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import SelectStepper from '../../select-stepper.vue'
import CardMascot from '../card-mascot.vue'
import { actNameList, animalNameList } from '../type'
type ActOption = ActName | 'random'
type AnimalOption = AnimalName | 'random'
const { t } = useI18n()
const cardRef = ref<InstanceType<typeof CardMascot>>()
const autoplay = ref(true)
const act = ref<ActOption>('random')
const animal = ref<AnimalOption>('random')
/** 正在演出的場次,結束後清空 */
const currentPayload = ref<ActPayload>()
const actOptionList: ActOption[] = ['random', ...actNameList]
const animalOptionList: AnimalOption[] = ['random', ...animalNameList]
/** 顯示「中文名稱 英文代號」,測試時對照程式碼比較方便 */
const actLabelMap = computed<Record<ActOption, string>>(() => {
const labelMap = { random: t('randomAct') } as Record<ActOption, string>
actNameList.forEach((name) => {
labelMap[name] = `${t(`act.${camelCase(name)}`)} ${name}`
})
return labelMap
})
const animalLabelMap = computed<Record<AnimalOption, string>>(() => {
const labelMap = { random: t('randomAnimal') } as Record<AnimalOption, string>
animalNameList.forEach((name) => {
labelMap[name] = `${t(`animal.${name}`)} ${name}`
})
return labelMap
})
const statusText = computed(() => {
const payload = currentPayload.value
if (!payload) {
return t('resting')
}
return t('performing', {
animal: animalLabelMap.value[payload.animal],
act: payload.act === 'custom' ? t('customAct') : actLabelMap.value[payload.act],
})
})
function handlePlay() {
cardRef.value?.play(
act.value === 'random' ? undefined : act.value,
animal.value === 'random' ? undefined : animal.value,
{ interrupt: true },
)
}
// 切換動作或動物就直接演,翻頁測試不用再按按鈕
watch([act, animal], () => handlePlay())
function handleActStart(payload: ActPayload) {
currentPayload.value = payload
}
function handleActEnd() {
currentPayload.value = undefined
}
</script>
<style lang="sass" scoped>
.mascot-card
background: light-dark(#FFF, #1e1e1e)
border: 1px solid light-dark(#e5e7eb, #3a3a3a)
color: light-dark(#374151, #d1d5db)
</style>電子報訂閱
查看範例原始碼
<template>
<div class="example-wrap w-full flex justify-center py-16">
<card-mascot
ref="cardRef"
:autoplay="false"
:animal-list="['chick']"
:act-list="idleActList"
class="newsletter-card max-w-full w-96 rounded-2xl p-8 shadow-lg"
>
<form
ref="formRef"
class="flex flex-col gap-4"
novalidate
:style="{ minHeight: formMinHeight > 0 ? `${formMinHeight}px` : undefined }"
@submit.prevent="handleSubmit"
>
<img
src="/low/painting-codfish-bakery.webp"
:alt="t('bannerAlt')"
class="aspect-video w-full rounded-xl object-cover"
>
<div class="text-2xl font-bold">
{{ t('title') }}
</div>
<span class="my-2 leading-relaxed opacity-80">
{{ t('description') }}
</span>
<template v-if="!isSubscribed">
<input
v-model="email"
type="email"
class="newsletter-input"
:placeholder="t('placeholder')"
@focus="handleFocus"
>
<!-- 常駐佔位,只切換透明度,錯誤訊息出現時版面不會位移 -->
<span
class="min-h-5 text-sm text-red-500 transition-opacity"
:class="errorMessage ? 'opacity-100' : 'opacity-0'"
aria-live="polite"
>
{{ errorMessage }}
</span>
<base-btn
class="self-start"
:label="t('subscribe')"
@click="handleSubmit"
/>
</template>
<template v-else>
<p class="text-green-600 font-bold">
{{ t('success') }}
</p>
<base-btn
class="self-start"
:label="t('again')"
@click="handleReset"
/>
</template>
</form>
</card-mascot>
</div>
</template>
<script setup lang="ts">
import type { ActName } from '../type'
import { useElementSize } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import CardMascot from '../card-mascot.vue'
import { actNameList } from '../type'
/** 帳號 @ 網域,網域至少兩段且每段不含點 */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/
const { t } = useI18n()
/** 互動時指定播放的動作,滑鼠移入的隨機演出就排除掉,留給對應的情境 */
const interactionActList: ActName[] = ['peek-top', 'slip', 'bounce', 'dance']
const idleActList = actNameList.filter((name) => !interactionActList.includes(name))
const cardRef = ref<InstanceType<typeof CardMascot>>()
const email = ref('')
const errorMessage = ref('')
const isSubscribed = ref(false)
const formRef = ref<HTMLFormElement>()
const { height: formHeight } = useElementSize(formRef, undefined, { box: 'border-box' })
/** 記住表單曾達到的最大高度,切成致謝內容時卡片不會縮短、版面不會跳動 */
const formMinHeight = ref(0)
watch(formHeight, (height) => {
formMinHeight.value = Math.max(formMinHeight.value, height)
})
function handleFocus() {
errorMessage.value = ''
// 有人靠近輸入框,探頭看看在打什麼
cardRef.value?.play('peek-top')
}
async function handleSubmit() {
if (isSubscribed.value)
return
if (!EMAIL_PATTERN.test(email.value)) {
errorMessage.value = t('invalid')
// 格式不對,嚇得滑倒
cardRef.value?.play('slip', undefined, { interrupt: true })
return
}
isSubscribed.value = true
// 訂閱成功,跳上來慶祝
await cardRef.value?.play('bounce', undefined, { interrupt: true })
await cardRef.value?.play('dance')
}
function handleReset() {
isSubscribed.value = false
email.value = ''
errorMessage.value = ''
}
</script>
<style lang="sass" scoped>
.newsletter-card
background: light-dark(#FFF, #1e1e1e)
border: 1px solid light-dark(#e5e7eb, #3a3a3a)
color: light-dark(#374151, #d1d5db)
.newsletter-input
padding: 0.625rem 0.875rem
border: 1px solid light-dark(#CCC, #555)
border-radius: 0.75rem
background: light-dark(#FAFAFA, #2a2a2a)
outline: none
transition: border-color 0.15s
&:focus
border-color: light-dark(#60a5fa, #3b82f6)
</style>原理
畫布比卡片大一圈
卡片裡鋪一層透明畫布,四周比卡片多出一圈,Babylon.js 就在這層畫布上渲染動物。畫布夾在卡片背景與內容之間,動物跑出卡片範圍才看得到。
隱形的遮罩
場景裡放一片跟卡片同形的平面,圓角照著卡片的 border-radius 切,材質只寫深度不寫顏色。動物排在後一層渲染,平面擋住的部分直接丟掉,卡片範圍內的動物就消失了,卡片背景要透明、要圓角都無所謂。現身與消失另外有兩百毫秒的淡入淡出,直接改整張畫布的透明度,整隻一起變淡。
像素就是座標
鏡頭距離依畫布高度算出來,讓 z = 0 平面上 1 單位剛好等於 1 px,卡片邊緣的座標直接就是寬高的一半。動物躲在平面後面一點的深度,位置與縮放再乘上透視補償倍率,探頭探多少都算得準。又寬又長的動物會退得更後面。size 只是身高上限,卡片太小時動物會跟著縮小。
動作用補間串起來
每個動作都是一段 async 函式,用 anime.js 補間動物的姿勢,例如角落探頭就是「斜著滑出來、東張西望、縮回去」三段。姿勢是 { x, y, yaw, pitch, roll, scaleX, scaleY } 這樣的物件,每幀渲染前才換算成場景座標套到模型上。play() 也吃自訂動作函式。捲出畫面、元件卸載或強制換場時,補間會一起停掉,動物以當下的姿勢淡出。
片段切換要混合
模型內建的動畫片段只有 idle、walk、run、dance 這幾種。每組 AnimationGroup 都開啟 enableBlending,新片段從當下姿勢慢慢混合過去,換片段時看不出接縫。
原始碼
API
Props
interface Props {
/** 會登場的動物,預設 24 種輪番上陣 */
animalList?: AnimalName[];
/** 會演出的動作,預設全部 */
actList?: ActName[];
/** 動物身高上限(px)。卡片太小時會依卡片寬高自動縮小,確保躲得進卡片後面。@default 80 */
size?: number;
/** 兩場演出之間的休息時間範圍(ms)。@default [1500, 4000] */
intervalRange?: [number, number];
/** 掛載後自動輪番演出。@default true */
autoplay?: boolean;
/** 滑鼠移入卡片時,若動物正在休息就立刻上場。@default true */
shouldPlayOnHover?: boolean;
/** 模型檔案所在目錄。@default '/kenney-cube-pets/' */
modelBaseUrl?: string;
}Emits
interface Emits {
/** 一場演出開始 */
actStart: [payload: ActPayload];
/** 一場演出結束,中途取消也算 */
actEnd: [payload: ActPayload];
}Methods
interface Expose {
/**
* 立刻演一場,演完才 resolve。未指定動作或動物就隨機挑,
* 也可以直接給一段自訂動作函式;有演出進行中時預設略過,interrupt 可強制換場
*/
play: (act?: ActName | ActFn, animal?: AnimalName, options?: PlayOptions) => Promise<void>;
/** 暫停自動輪播,進行中的演出會演完 */
pause: () => void;
/** 恢復自動輪播 */
resume: () => void;
}Slots
interface Slots {
default?: (data: {
/** 是否有動物正在演出 */
isActing: boolean;
/** 目前演出的動作,自訂動作為 custom */
act?: ActPayload['act'];
}) => unknown;
}