- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶), Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion, threaded through LottiePlayer (scaleX(-1)) since the asset faces left. - Mascot size now scales with the viewport: --petal-companion-size clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all derive from it. - Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble). - Bubbles linger longer for a bilingual ESL read: baseline 9s→14s, cheer 6s→9s, per-char 45→55ms, cap 20s→32s. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
88 lines
3.1 KiB
TypeScript
88 lines
3.1 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
// Light build: drops the eval-based expression engine (smaller bundle, no eval
|
|
// warning). Plenty for a looping mascot; swap to 'lottie-web' only if an asset
|
|
// genuinely needs After Effects expressions.
|
|
import lottie, { type AnimationItem } from 'lottie-web/build/player/lottie_light'
|
|
|
|
interface Props {
|
|
// Parsed Lottie JSON (imported, so it bundles offline). When undefined the
|
|
// `fallback` is rendered instead — letting the companion ship before any
|
|
// animation asset exists.
|
|
animationData?: object
|
|
loop?: boolean
|
|
className?: string
|
|
// Mirror the animation left↔right (for assets drawn facing the wrong way).
|
|
flip?: boolean
|
|
fallback: React.ReactNode
|
|
}
|
|
|
|
// Tighten the SVG viewBox to the animation's actual drawn content so an asset
|
|
// with a lot of empty artboard padding (common with stock Lottie files) fills
|
|
// the badge instead of floating tiny in the middle. We union the content bbox
|
|
// across a few frames (the mascot moves) and make it square so it isn't
|
|
// distorted by the square container.
|
|
function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
|
try {
|
|
const frames = anim.getDuration(true)
|
|
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
|
|
const SAMPLES = 6
|
|
for (let i = 0; i < SAMPLES; i++) {
|
|
anim.goToAndStop(Math.floor(((frames - 1) * i) / (SAMPLES - 1)), true)
|
|
const b = svg.getBBox()
|
|
if (b.width === 0 || b.height === 0) continue
|
|
x1 = Math.min(x1, b.x)
|
|
y1 = Math.min(y1, b.y)
|
|
x2 = Math.max(x2, b.x + b.width)
|
|
y2 = Math.max(y2, b.y + b.height)
|
|
}
|
|
if (!isFinite(x1)) return
|
|
const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2
|
|
const side = Math.max(x2 - x1, y2 - y1) * 1.14 // ~7% breathing room each side
|
|
svg.setAttribute('viewBox', `${cx - side / 2} ${cy - side / 2} ${side} ${side}`)
|
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
|
} catch {
|
|
/* getBBox unsupported / not laid out — leave the original viewBox */
|
|
} finally {
|
|
anim.goToAndPlay(0, true)
|
|
}
|
|
}
|
|
|
|
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
|
// offline). Reloads the animation whenever the data changes — moods swap by
|
|
// passing a different `animationData`.
|
|
export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const anim = useRef<AnimationItem | null>(null)
|
|
|
|
useEffect(() => {
|
|
const container = ref.current
|
|
if (!container || !animationData) return
|
|
const item = lottie.loadAnimation({
|
|
container,
|
|
renderer: 'svg',
|
|
loop,
|
|
autoplay: true,
|
|
animationData,
|
|
})
|
|
anim.current = item
|
|
item.addEventListener('DOMLoaded', () => {
|
|
const svg = container.querySelector('svg')
|
|
if (svg) fitToContent(item, svg as SVGSVGElement)
|
|
})
|
|
return () => {
|
|
item.destroy()
|
|
anim.current = null
|
|
}
|
|
}, [animationData, loop])
|
|
|
|
if (!animationData) return <>{fallback}</>
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={className}
|
|
style={flip ? { transform: 'scaleX(-1)' } : undefined}
|
|
aria-hidden
|
|
/>
|
|
)
|
|
}
|