Files
petal/web/src/components/Companion/LottiePlayer.tsx
prosolis adc0cdff1c Add butterfly/parrot/wiggle-dog companions; scale mascot + tips
- 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
2026-06-26 11:08:55 -07:00

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
/>
)
}