吉祥物卡片 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"
/>
</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>牛與熊
一買就跌、一賣就漲。
有可愛小動物陪伴,比較不會想不開。(´;ω;`)ヾ(・ω・`〃)
投資一定有風險,鱈魚幣有賺有賠,申購前請先佔好公園床位。
查看範例原始碼
<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: clip 加 overflow-clip-margin、clip-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;
}