Skip to content

Mascot Card card

A bunch of little animals live behind the card, peeking out, running around, and jumping out to remind you they exist. (๑•̀ㅂ•́)و✧

On the Microsoft Clarity website, animals sneak a peek from behind the cards. It was way too cute, so I decided to make my own right away. (*´∀`)~♥

The models come from Kenney's Cube Pets, licensed under CC0. All 24 blocky animals ship with built-in idle, run, dance and other animation clips, ready to use as is. ◝( •ω• )◟

Thank you, Kenney! Praise be to Kenney! ੭ ˙ᗜ˙ )੭

Tech Keywords

NameDescription
Babylon.js3D engine
glTFStandard transmission format for 3D models; .glb is its binary bundle carrying meshes, materials, and animations
Depth BufferStores the distance from the camera for every pixel so the renderer can tell which surface is in front
Anime.jsLightweight JavaScript animation library
IntersectionObserverDetects when elements enter or leave the viewport

Usage Examples

Basic Usage

Hover over the card and an animal comes out to say hi right away. ( ´ ▽ ` )ノ

休息中
鱈魚的酷酷元件

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

View example source code
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>

The Bull and the Bear

It drops the moment you buy and pumps the moment you sell.

At least there are cute little animals to keep you company. (´;ω;`)ヾ(・ω・`〃)

COD鱈魚幣
▲ +1.8%
$ 0.0043
盤整 ( ˘•ω•˘ )持有 0 枚・損益 —

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

View example source code
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>

How It Works

A canvas one size larger than the card

A transparent canvas is laid inside the card, extending a margin beyond it on every side, and Babylon.js renders the animals onto it. The canvas sits between the card background and the content, so an animal only shows up once it leaves the card area.

Positioning that extra margin with absolute would fold it into the page's scrollable area, adding a stray stretch of scrollbar; since the margin follows the animal size, which follows the card size, the scrollbar would appear and vanish in a loop and jitter. Neither overflow: clip with overflow-clip-margin nor clip-path helps, because anything still painted counts. A fixed canvas contributes nothing to any scrollable area.

Its position is then handed to CSS Anchor Positioning: the card carries an anchor-name and the canvas pins itself with top: calc(anchor(top) - margin), resolved by the layout engine so it moves in lockstep with the card while scrolling. Correcting the position from getBoundingClientRect() every frame instead always lags a beat, because scrolling is driven by the compositor and the page has already moved by the time the main thread reads the new coordinates; the occluder drifts along with it and the animal shows parts that ought to stay hidden, which gets worse while the main thread is busy rendering an act. Safari and Firefox do not support anchors yet, so a failing @supports falls back to the per-frame correction, where the positioning origin is derived from the current offset and a transformed ancestor changing the containing block does not throw it off.

An invisible occluder

The scene holds a plane shaped exactly like the card, its corners rounded to match the border-radius, with a material that writes depth but no color. The animals render in a later rendering group, and everything behind the plane is discarded, so anything inside the card area disappears while the card background can stay transparent or rounded. Appearing and vanishing also fade over two hundred milliseconds by changing the opacity of the whole canvas, so the entire body fades as one.

Pixels are coordinates

The camera distance is derived from the canvas height so that one unit on the z = 0 plane equals one pixel, which puts the card edges at half the card width and height. The animal hides slightly behind that plane, so its position and scale are multiplied by a perspective compensation factor and every peek lands exactly where intended. Wide, long animals retreat further back. size is only an upper bound, and the animal shrinks when the card is too small.

Acts stitched from tweens

Every act is an async function that tweens the animal's pose with anime.js. Peeking from a corner, for example, is three steps: lean out sideways, look around, slide back. The pose is a { x, y, yaw, pitch, roll, scaleX, scaleY } object, converted to scene coordinates right before each frame renders. play() also accepts a custom act function. When the card scrolls out of view, the component unmounts, or an act is interrupted, every tween stops together and the animal keeps its current pose while retreating behind the card as it fades, as if caught in the act and ducking away. The landing spot is the shortest retreat to where the occluder covers it, which naturally points toward the card center; an animal already tucked away simply fades in place.

Blending between clips

The models only ship with a handful of animation clips such as idle, walk, run, and dance. Every AnimationGroup has enableBlending turned on, so a new clip blends in from the current pose and switching clips leaves no visible seam.

Source Code

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