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"
        />
      </div>

      <!-- 狀態文字獨佔一行且不換行,換成長句時控制項不會被推著跑 -->
      <span
        class="truncate text-sm opacity-70"
        :title="statusText"
      >
        {{ statusText }}
      </span>

      <!-- 寬度夠就並排,窄畫面才自動換行 -->
      <div class="flex flex-wrap gap-4">
        <select-stepper
          v-model="act"
          class="max-w-96 min-w-60 flex-1"
          :label="t('actTitle')"
          :options="actOptionList"
          :option-label-map="actLabelMap"
        />

        <select-stepper
          v-model="animal"
          class="max-w-96 min-w-60 flex-1"
          :label="t('animalTitle')"
          :options="animalOptionList"
          :option-label-map="animalLabelMap"
        />
      </div>
    </div>

    <div class="flex justify-center py-16">
      <card-mascot
        ref="cardRef"
        :autoplay
        pick-order="sequence"
        :interval-range="[100, 1000]"
        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 | 'sequence'
type AnimalOption = AnimalName | 'sequence'

const { t } = useI18n()

const cardRef = ref<InstanceType<typeof CardMascot>>()
const autoplay = ref(true)
const act = ref<ActOption>('sequence')
const animal = ref<AnimalOption>('sequence')
/** 正在演出的場次,結束後清空 */
const currentPayload = ref<ActPayload>()

const actOptionList: ActOption[] = ['sequence', ...actNameList]
const animalOptionList: AnimalOption[] = ['sequence', ...animalNameList]

/** 顯示「中文名稱 英文代號」,測試時對照程式碼比較方便 */
const actLabelMap = computed<Record<ActOption, string>>(() => {
  const labelMap = { sequence: t('sequenceAct') } as Record<ActOption, string>
  actNameList.forEach((name) => {
    labelMap[name] = `${t(`act.${camelCase(name)}`)} ${name}`
  })

  return labelMap
})

const animalLabelMap = computed<Record<AnimalOption, string>>(() => {
  const labelMap = { sequence: t('sequenceAnimal') } 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 === 'sequence' ? undefined : act.value,
    animal.value === 'sequence' ? 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>

牛與熊

一買就跌、一賣就漲。

有可愛小動物陪伴,比較不會想不開。(´;ω;`)ヾ(・ω・`〃)

COD鱈魚幣
▼ -5.8%
$ 0.0040
盤整 ( ˘•ω•˘ )持有 0 枚・損益 —

投資一定有風險,鱈魚幣有賺有賠,申購前請先佔好公園床位。

查看範例原始碼
vue
<template>
  <div
    ref="wrapperRef"
    class="example-wrap w-full flex justify-center py-16"
  >
    <card-mascot
      ref="cardRef"
      :animal-list="regimeSetting.animalList"
      :act-list="regimeSetting.actList"
      :interval-range="regimeSetting.intervalRange"
      class="market-card max-w-full w-96 rounded-2xl p-6 shadow-lg"
    >
      <div class="flex flex-col gap-4">
        <div class="flex items-start justify-between gap-3">
          <div class="flex flex-col">
            <span class="text-xs tracking-widest opacity-60">
              {{ t('symbol') }}
            </span>
            <span class="text-xl font-bold">
              {{ t('name') }}
            </span>
          </div>

          <span
            class="market-badge rounded-full px-2.5 py-1 text-sm font-bold tabular-nums"
            :class="trendColorClass"
          >
            {{ changeText }}
          </span>
        </div>

        <div
          class="text-4xl font-bold tabular-nums"
          :class="trendColorClass"
        >
          $ {{ priceText }}
        </div>

        <!-- K 棒與成交量全用矩形畫,viewBox 拉伸也不會讓線條粗細跑掉 -->
        <svg
          class="market-chart h-28 w-full"
          :viewBox="chartViewBox"
          preserveAspectRatio="none"
          aria-hidden="true"
        >
          <!-- 開盤價基準線,漲跌都以它為準 -->
          <line
            class="market-baseline"
            x1="0"
            :y1="baselineY"
            :x2="CHART_WIDTH"
            :y2="baselineY"
            stroke="currentColor"
            stroke-width="1"
            stroke-dasharray="3 3"
            vector-effect="non-scaling-stroke"
          />

          <g
            v-for="(candle, index) in candleShapeList"
            :key="index"
            :class="candle.isUp ? upColorClass : downColorClass"
            fill="currentColor"
          >
            <rect
              :x="candle.wickX"
              :y="candle.wickY"
              :width="candle.wickWidth"
              :height="candle.wickHeight"
            />
            <rect
              :x="candle.bodyX"
              :y="candle.bodyY"
              :width="candle.bodyWidth"
              :height="candle.bodyHeight"
            />
            <rect
              class="market-volume"
              :x="candle.volumeX"
              :y="candle.volumeY"
              :width="candle.volumeWidth"
              :height="candle.volumeHeight"
            />
          </g>
        </svg>

        <div class="flex items-center justify-between gap-3 text-sm">
          <span class="font-bold">
            {{ t(`regime.${regime}`) }}
          </span>
          <span class="tabular-nums opacity-70">
            {{ holdingText }}
          </span>
        </div>

        <!-- 常駐佔位,只切換透明度,訊息出現時版面不會位移 -->
        <span
          class="min-h-5 text-sm font-bold transition-opacity"
          :class="message ? 'opacity-100' : 'opacity-0'"
          aria-live="polite"
        >
          {{ message }}
        </span>

        <div class="flex gap-3">
          <base-btn
            class="market-trade-btn market-trade-btn--buy flex-1"
            @click="handleBuy"
          >
            <span class="market-trade-label">
              <span
                class="market-trade-arrow"
                :class="upColorClass"
                aria-hidden="true"
              >▲</span>
              {{ t('buy') }}
            </span>
          </base-btn>

          <base-btn
            class="market-trade-btn market-trade-btn--sell flex-1"
            :class="{ 'opacity-50': holdingCount === 0 }"
            :disabled="holdingCount === 0"
            ignore-click
            @click="handleSell"
          >
            <span class="market-trade-label">
              <span
                class="market-trade-arrow"
                :class="downColorClass"
                aria-hidden="true"
              >▼</span>
              {{ t('sell') }}
            </span>
          </base-btn>
        </div>

        <p class="text-xs leading-relaxed opacity-50">
          {{ t('disclaimer') }}
        </p>
      </div>
    </card-mascot>
  </div>
</template>

<script setup lang="ts">
import type { ActName, AnimalName } from '../type'
import { useElementVisibility, useIntervalFn, useTimeoutFn } from '@vueuse/core'
import { random, sample } from 'lodash-es'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import CardMascot from '../card-mascot.vue'

type Regime = 'bull' | 'bear' | 'flat'

interface RegimeSetting {
  /** 每格的漂移比例,正值上漲 */
  drift: number;
  /** 每格的雜訊幅度 */
  noise: number;
  animalList: AnimalName[];
  actList: ActName[];
  intervalRange: [number, number];
  /** 轉入此行情時立刻演的招牌動作 */
  signatureAct?: ActName;
}

/** 一根 K 棒 */
interface Candle {
  open: number;
  high: number;
  low: number;
  close: number;
  volume: number;
  /** 已經吃進幾格價格,滿了就換下一根 */
  tickCount: number;
}

/** 一根 K 棒在圖表上的位置,單位為 viewBox 座標 */
interface CandleShape {
  isUp: boolean;
  bodyX: number;
  bodyY: number;
  bodyWidth: number;
  bodyHeight: number;
  wickX: number;
  wickY: number;
  wickWidth: number;
  wickHeight: number;
  volumeX: number;
  volumeY: number;
  volumeWidth: number;
  volumeHeight: number;
}

const regimeList: Regime[] = ['bull', 'bear', 'flat']

/** 牛市乳牛、熊市北極熊,盤整時兩隻輪流探頭觀望。兩隻都一身白,靠描邊才不會和白卡片糊成一團 */
const regimeSettingMap: Record<Regime, RegimeSetting> = {
  bull: {
    drift: 0.012,
    noise: 0.02,
    animalList: ['cow'],
    actList: ['backflip', 'dance', 'jump', 'bounce', 'cartwheel', 'moonwalk'],
    intervalRange: [800, 2000],
    signatureAct: 'backflip',
  },
  bear: {
    drift: -0.012,
    noise: 0.02,
    animalList: ['polar'],
    actList: ['slip', 'tumble', 'hang', 'sneeze', 'lying-peek'],
    intervalRange: [800, 2000],
    signatureAct: 'tumble',
  },
  flat: {
    drift: 0,
    noise: 0.008,
    animalList: ['cow', 'polar'],
    actList: ['sneak', 'peek-left', 'peek-right', 'peek-top', 'hide-and-seek', 'inch', 'ninja-vanish'],
    intervalRange: [2000, 4000],
  },
}

/** 開盤價。漲跌幅的基準,同時是價格回歸的錨點 */
const SESSION_OPEN = 0.0042
/** 圖表顯示的 K 棒數 */
const CANDLE_COUNT = 24
/** 每根 K 棒由幾格價格組成 */
const TICKS_PER_CANDLE = 4
/** 每格間隔(ms) */
const TICK_INTERVAL = 500
/** 行情自動切換的間隔範圍(ms) */
const REGIME_DURATION_RANGE: [number, number] = [4000, 8000]
/** 買賣後行情鎖定的時間範圍(ms)。比自動切換久一點,韭菜的痛才夠深 */
const FORCED_REGIME_DURATION_RANGE: [number, number] = [6000, 10000]
/** 買賣當下價格跳動的比例範圍 */
const TRADE_SHOCK_RANGE: [number, number] = [0.08, 0.15]
/** 每次買入的數量 */
const TRADE_AMOUNT = 100
/** 單格波動換算成成交量的倍率 */
const VOLUME_SWING_WEIGHT = 80
/** 單格波動計入成交量的上限 */
const VOLUME_SWING_CAP = 0.05

const CHART_WIDTH = 100
/** K 棒區高度 */
const PRICE_HEIGHT = 38
/** 成交量區高度 */
const VOLUME_HEIGHT = 10
/** 兩區之間的間距 */
const BAND_GAP = 3
const CHART_HEIGHT = PRICE_HEIGHT + BAND_GAP + VOLUME_HEIGHT
/** 每根 K 棒分到的水平寬度 */
const SLOT_WIDTH = CHART_WIDTH / CANDLE_COUNT
/** 實體佔格子的比例 */
const BODY_RATIO = 0.62
/** 影線佔格子的比例 */
const WICK_RATIO = 0.16
/** 最小高度。平盤的十字線也要看得見 */
const MIN_BAR_HEIGHT = 0.5
/** Y 軸上下各留的比例 */
const RANGE_PADDING_RATIO = 0.12
/** Y 軸每次往目標挪多少 */
const RANGE_EASE_RATIO = 0.25

const { t, locale } = useI18n()

const wrapperRef = ref<HTMLDivElement>()
const cardRef = ref<InstanceType<typeof CardMascot>>()
const chartViewBox = `0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`

const regime = ref<Regime>('flat')
const regimeSetting = computed(() => regimeSettingMap[regime.value])

const holdingCount = ref(0)
const averageCost = ref(0)
const message = ref('')

/** 開一根新的 K 棒,四價都從這個價格起算 */
function createCandle(price: number): Candle {
  return { open: price, high: price, low: price, close: price, volume: 0, tickCount: 0 }
}

/**
 * 把一格新價格吃進 K 棒。收盤價跟著走,最高最低只進不退。
 *
 * 成交量以波動為主、底噪為輔,價格跳得越兇量越大;
 * 單格波動先封頂,自己下單那一根才只是明顯的量能尖峰,不會把其他棒子壓成一條線
 */
function applyPrice(candle: Candle, price: number) {
  const previousClose = candle.close
  const swing = Math.min(Math.abs(price - previousClose) / previousClose, VOLUME_SWING_CAP)

  candle.close = price
  candle.high = Math.max(candle.high, price)
  candle.low = Math.min(candle.low, price)
  candle.volume += random(0.05, 0.25, true) + swing * VOLUME_SWING_WEIGHT
  candle.tickCount += 1
}

/** 開場先鋪滿一段盤整走勢,圖表不會從空白慢慢長出來 */
function buildInitialCandleList() {
  const { noise } = regimeSettingMap.flat
  const list: Candle[] = []
  let price = SESSION_OPEN

  for (let index = 0; index < CANDLE_COUNT - 1; index += 1) {
    const candle = createCandle(price)
    for (let step = 0; step < TICKS_PER_CANDLE; step += 1) {
      price *= 1 + random(-noise, noise, true)
      applyPrice(candle, price)
    }
    list.push(candle)
  }
  // 最後一根留著繼續長
  list.push(createCandle(price))

  return list
}

const candleList = ref<Candle[]>(buildInitialCandleList())
const currentPrice = computed(() => candleList.value.at(-1)?.close ?? SESSION_OPEN)

/** 與開盤價相比的漲跌幅。基準固定,價格沒動百分比就不會自己變 */
const changeRate = computed(() => (currentPrice.value - SESSION_OPEN) / SESSION_OPEN)

/** Y 軸範圍。慢慢追上實際高低點,極值滾出圖表時整張圖才不會突然跳一下 */
const priceRange = ref({ min: SESSION_OPEN, max: SESSION_OPEN })

/**
 * 把 Y 軸範圍往目標挪一點。
 *
 * 開盤價一定含在範圍內,那條基準線才不會被擠出圖外
 */
function easePriceRange(ratio = RANGE_EASE_RATIO) {
  let min = SESSION_OPEN
  let max = SESSION_OPEN
  candleList.value.forEach((candle) => {
    min = Math.min(min, candle.low)
    max = Math.max(max, candle.high)
  })

  const padding = (max - min) * RANGE_PADDING_RATIO || SESSION_OPEN * 0.02
  const target = { min: min - padding, max: max + padding }
  const current = priceRange.value

  priceRange.value = {
    min: current.min + (target.min - current.min) * ratio,
    max: current.max + (target.max - current.max) * ratio,
  }
}
easePriceRange(1)

/** 推進一格價格。目前這根吃滿了就換下一根,最舊的那根掉出圖表 */
function pushPrice(price: number) {
  const candle = candleList.value.at(-1)
  if (!candle)
    return

  applyPrice(candle, price)

  if (candle.tickCount >= TICKS_PER_CANDLE) {
    candleList.value.push(createCandle(price))
    if (candleList.value.length > CANDLE_COUNT) {
      candleList.value.shift()
    }
  }

  easePriceRange()
}

function tick() {
  const { drift, noise } = regimeSetting.value
  // 偏離開盤價太遠就輕輕拉回來,掛一整天也不會漲到月球或歸零
  const pullback = Math.log(SESSION_OPEN / currentPrice.value) * 0.01
  pushPrice(currentPrice.value * (1 + drift + pullback + random(-noise, noise, true)))
}

const regimeDelay = ref(random(...REGIME_DURATION_RANGE))
const { start: startRegimeTimer, stop: stopRegimeTimer } = useTimeoutFn(
  () => {
    message.value = ''
    switchRegime(pickNextRegime())
  },
  regimeDelay,
  { immediate: false },
)

/** 換一種不同的行情 */
function pickNextRegime() {
  const candidateList = regimeList.filter((name) => name !== regime.value)
  return sample(candidateList) ?? 'flat'
}

/**
 * 換行情並安排下一次自動切換。
 *
 * 牛市、熊市各有招牌動作,直接打斷進行中的演出,反應不用等下一場;
 * 轉盤整時 props 要等下一次渲染才會換成新名單,等一拍再讓元件自己挑
 */
async function switchRegime(next: Regime, durationRange = REGIME_DURATION_RANGE) {
  regime.value = next
  regimeDelay.value = random(...durationRange)
  startRegimeTimer()

  const { signatureAct, animalList } = regimeSettingMap[next]
  if (signatureAct) {
    cardRef.value?.play(signatureAct, animalList[0], { interrupt: true })
    return
  }

  await nextTick()
  cardRef.value?.play(undefined, undefined, { interrupt: true })
}

const { pause: pauseTicking, resume: resumeTicking } = useIntervalFn(tick, TICK_INTERVAL, { immediate: false })
const isVisible = useElementVisibility(wrapperRef)

// 捲出畫面就休市,動物也不用白演
watch(isVisible, (visible) => {
  if (visible) {
    resumeTicking()
    startRegimeTimer()
    return
  }

  pauseTicking()
  stopRegimeTimer()
})

function handleBuy() {
  const price = currentPrice.value
  const totalCost = averageCost.value * holdingCount.value + price * TRADE_AMOUNT
  holdingCount.value += TRADE_AMOUNT
  averageCost.value = totalCost / holdingCount.value

  // 一買就跌
  pushPrice(price * (1 - random(...TRADE_SHOCK_RANGE, true)))
  message.value = t('boughtMessage')
  switchRegime('bear', FORCED_REGIME_DURATION_RANGE)
}

function handleSell() {
  holdingCount.value = 0
  averageCost.value = 0

  // 一賣就漲
  pushPrice(currentPrice.value * (1 + random(...TRADE_SHOCK_RANGE, true)))
  message.value = t('soldMessage')
  switchRegime('bull', FORCED_REGIME_DURATION_RANGE)
}

/** 台股習慣紅漲綠跌,其他市場相反 */
const isRedForUp = computed(() => locale.value.startsWith('zh'))

/** 漲跌對應的文字顏色。徽章、現價、K 棒與買賣箭頭共用同一套 */
function getTrendColorClass(isUp: boolean) {
  return isUp === isRedForUp.value ? 'text-red-500' : 'text-green-500'
}

const trendColorClass = computed(() => getTrendColorClass(changeRate.value >= 0))
const upColorClass = computed(() => getTrendColorClass(true))
const downColorClass = computed(() => getTrendColorClass(false))

const priceText = computed(() => currentPrice.value.toFixed(4))
const changeText = computed(() => {
  const percent = (changeRate.value * 100).toFixed(1)
  return changeRate.value >= 0 ? `▲ +${percent}%` : `▼ ${percent}%`
})
const holdingText = computed(() => {
  if (holdingCount.value === 0) {
    return t('holding', { count: 0, profit: '—' })
  }

  const rate = (currentPrice.value - averageCost.value) / averageCost.value * 100
  const profit = `${rate >= 0 ? '+' : ''}${rate.toFixed(1)}%`
  return t('holding', { count: holdingCount.value, profit })
})

/** 開盤價在圖表上的高度 */
const baselineY = computed(() => {
  const { min, max } = priceRange.value
  return (max - SESSION_OPEN) / (max - min || 1) * PRICE_HEIGHT
})

/** 每根 K 棒的實體、影線與成交量柱 */
const candleShapeList = computed<CandleShape[]>(() => {
  const { min, max } = priceRange.value
  const span = max - min || 1
  const maxVolume = Math.max(...candleList.value.map(({ volume }) => volume), 1)
  const bodyWidth = SLOT_WIDTH * BODY_RATIO
  const wickWidth = SLOT_WIDTH * WICK_RATIO

  /** 價格換算成圖表上的高度,價格越高越靠上 */
  const getY = (price: number) => (max - price) / span * PRICE_HEIGHT

  return candleList.value.map((candle, index) => {
    const centerX = (index + 0.5) * SLOT_WIDTH
    const openY = getY(candle.open)
    const closeY = getY(candle.close)
    const highY = getY(candle.high)
    const volumeHeight = candle.volume / maxVolume * VOLUME_HEIGHT

    return {
      isUp: candle.close >= candle.open,
      bodyX: centerX - bodyWidth / 2,
      bodyY: Math.min(openY, closeY),
      bodyWidth,
      bodyHeight: Math.max(Math.abs(closeY - openY), MIN_BAR_HEIGHT),
      wickX: centerX - wickWidth / 2,
      wickY: highY,
      wickWidth,
      wickHeight: Math.max(getY(candle.low) - highY, MIN_BAR_HEIGHT),
      volumeX: centerX - bodyWidth / 2,
      volumeY: CHART_HEIGHT - volumeHeight,
      volumeWidth: bodyWidth,
      volumeHeight,
    }
  })
})
</script>

<style lang="sass" scoped>
.market-card
  background: light-dark(#FFF, #1e1e1e)
  border: 1px solid light-dark(#e5e7eb, #3a3a3a)
  color: light-dark(#374151, #d1d5db)

// 底色跟著漲跌顏色走,只取一點點
.market-badge
  background: color-mix(in srgb, currentColor 12%, transparent)

// 基準線與成交量都是配角,壓淡才不會搶走 K 棒
.market-baseline
  opacity: 0.28

.market-volume
  opacity: 0.4

// 按鈕維持中性色,只有箭頭帶漲跌色,整片色塊反而顯得廉價
.market-card .market-trade-btn
  padding: 0.625rem 0.875rem
  border-radius: 0.75rem
  border-color: light-dark(#DDD, #4d4d4d)
  background: light-dark(linear-gradient(#FFF, #F4F4F6), linear-gradient(#3a3a3a, #2e2e2e))
  box-shadow: inset 0 1px 0 light-dark(#FFF, #FFFFFF14), 0 1px 2px light-dark(#0000000D, #00000040)
  font-weight: 700
  letter-spacing: 0.02em
  transition: background 0.15s, border-color 0.15s, scale 0.4s
  &:hover
    border-color: light-dark(#C9C9CF, #5c5c5c)
    background: light-dark(linear-gradient(#FFF, #EDEDF1), linear-gradient(#424242, #333))
  &:active
    background: light-dark(linear-gradient(#F1F1F4, #E8E8EE), linear-gradient(#333, #292929))

.market-trade-label
  display: inline-flex
  align-items: center
  justify-content: center
  gap: 0.4rem

// 滑鼠移入時箭頭往自己的方向挪一下
.market-trade-arrow
  font-size: 0.85em
  transition: translate 0.2s ease

.market-card .market-trade-btn--buy:hover .market-trade-arrow
  translate: 0 -2px

.market-card .market-trade-btn--sell:hover .market-trade-arrow
  translate: 0 2px

@media (prefers-reduced-motion: reduce)
  .market-trade-arrow
    transition: none

  .market-card .market-trade-btn--buy:hover .market-trade-arrow,
  .market-card .market-trade-btn--sell:hover .market-trade-arrow
    translate: 0
</style>

原理

畫布比卡片大一圈

卡片裡鋪一層透明畫布,四周比卡片多出一圈,Babylon.js 就在這層畫布上渲染動物。畫布夾在卡片背景與內容之間,動物跑出卡片範圍才看得到。

多出來這一圈若用 absolute 定位,會被算進頁面捲動範圍,捲軸憑空多一截;動物尺寸隨卡片寬高變動,捲軸出現又消失,還會來回跳動。overflow: clipoverflow-clip-marginclip-path 都救不了,只要畫得到的地方都算數。改用 fixed 就完全不列入捲動範圍。

位置則交給 CSS Anchor Positioning:卡片掛上 anchor-name,畫布用 top: calc(anchor(top) - 邊距) 釘上去,排版層直接算,捲動時與卡片同步移動。若改由 JS 每幀讀 getBoundingClientRect() 再補 transform,捲動由合成緒驅動,主緒讀到新座標時畫面早已捲過去,畫布必定慢一拍,遮罩跟著錯開,動物會在捲動途中露出本該被擋住的部分,演出時主緒忙著渲染更明顯。Safari 與 Firefox 尚未支援錨點,@supports 沒過才退回每幀校正,定位原點由目前位移反推,祖先若有 transform 讓 fixed 換了參考框也不會跑掉。

隱形的遮罩

場景裡放一片跟卡片同形的平面,圓角照著卡片的 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[];
  /** 未指定時怎麼挑動作與動物:random 隨機且避免連續重複,sequence 依清單順序輪流。@default 'random' */
  pickOrder?: 'random' | 'sequence';
  /** 動物身高上限(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;
  /** 描邊顏色,任何 CSS 顏色都行,含透明度。淺色動物貼在淺色卡片上容易糊成一團,預設描一圈淡灰;給空字串或全透明就不描邊。@default '#9e9e9e4d' */
  outlineColor?: string;
  /** 描邊粗細(px)。@default 1 */
  outlineWidth?: number;
}

Emits

interface Emits {
  /** 一場演出開始 */
  actStart: [payload: ActPayload];
  /** 一場演出結束,中途取消也算 */
  actEnd: [payload: ActPayload];
}

Methods

interface Expose {
  /**
   * 立刻演一場,演完才 resolve。未指定動作或動物就依 pickOrder 挑,
   * 也可以直接給一段自訂動作函式;有演出進行中時預設略過,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.86.3