Skip to content

吉祥物卡片 card

卡片後面住了一群小動物,時不時探頭、亂跑、跳出來刷存在感。(๑•̀ㅂ•́)و✧

Microsoft Clarity 的網頁中,卡片後面會有動物偷偷探頭,可愛到我當場決定也要做一個。(*´∀`)~♥

模型來自 Kenney 的 Cube Pets,CC0 授權,24 隻方塊小動物通通內建 idle、run、dance 等動畫片段,直接拿來用就好。◝( •ω• )◟

感謝 Kenney!讚嘆 Kenney!੭ ˙ᗜ˙ )੭

技術關鍵字

名稱描述
Babylon.js3D 引擎
glTF3D 模型的通用傳輸格式,.glb 為其二進位打包版本,可同時收錄網格、材質與動畫
Depth Buffer深度緩衝,記錄每個像素離鏡頭的距離,渲染時據此判斷誰擋住誰
Anime.js輕量級 JavaScript 動畫函式庫
IntersectionObserver偵測元素是否進入或離開視窗

使用範例

基本用法

滑鼠移到卡片上,動物會馬上出來打招呼。( ´ ▽ ` )ノ

休息中
鱈魚的酷酷元件

這張卡片後面住了一群小動物,有空就會出來刷存在感。

查看範例原始碼
vue
<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>

電子報訂閱

查看範例原始碼
vue
<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;
}

v0.84.2