"use client"; import { useEffect, useId, useRef, useState } from "react"; import { defaultCycle } from "@/lib/bloub-official/cycles"; import { NOTIF_BLUE } from "@/lib/bloub-official/decor"; import { type BotFrame, BotEngine } from "@/lib/bloub-official/engine"; import { DEFAULT_EXPRESSION, EXPRESSION_BY_ID, } from "@/lib/bloub-official/expressions"; import { DEMI_VIEWBOX, RAYON } from "@/lib/bloub-official/repere"; import { SHAPE_BY_ID } from "@/lib/bloub-official/skins"; import type { StateId } from "@/lib/bloub-official/states"; import { cn } from "@/lib/utils"; export type BloubAvatarProps = { animated?: boolean; className?: string; label?: string; playSequence?: boolean; size?: number; state?: StateId; surface?: "chat" | "page"; }; export function BloubAvatar({ animated = true, className, label = "AI 助手", playSequence = false, size = 32, state = "idle", surface = "chat", }: BloubAvatarProps) { const rawId = useId(); const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, ""); const maskId = `bloub-mask-${uid}`; const engineRef = useRef(null); const clockRef = useRef(0); if (!engineRef.current) { engineRef.current = new BotEngine( RAYON, state, SHAPE_BY_ID.get("squircle")?.radii ?? null, EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null ); } const [frame, setFrame] = useState(() => engineRef.current!.sample(0) ); useEffect(() => { if (playSequence) return; const engine = engineRef.current; if (!engine || engine.state === state) return; engine.setState(state, clockRef.current); setFrame(engine.sample(clockRef.current)); }, [playSequence, state]); useEffect(() => { const engine = engineRef.current; if (!engine) return; const cycle = defaultCycle().blocks; const firstState = playSequence ? cycle[0]?.state ?? "idle" : engine.state; engine.reset(firstState, 0); clockRef.current = 0; setFrame(engine.sample(0)); const reducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; if (!animated || reducedMotion) return; let animationFrame = 0; let lastFrameAt = 0; let blockIndex = 0; let nextBlockAt = cycle[0]?.duration ?? Number.POSITIVE_INFINITY; const tick = (timestamp: number) => { const delta = lastFrameAt ? Math.min((timestamp - lastFrameAt) / 1000, 0.064) : 0; lastFrameAt = timestamp; clockRef.current += delta; if (playSequence && cycle.length > 0) { while (clockRef.current >= nextBlockAt) { blockIndex = (blockIndex + 1) % cycle.length; const nextBlock = cycle[blockIndex]!; engine.setState(nextBlock.state, nextBlockAt); nextBlockAt += nextBlock.duration; } } setFrame(engine.sample(clockRef.current)); animationFrame = window.requestAnimationFrame(tick); }; animationFrame = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(animationFrame); }, [animated, playSequence]); const renderDot = ( dot: BotFrame["dots"][number], index: number, layer: "back" | "front" ) => { const fill = dot.color ?? "currentColor"; const common = { fill, opacity: dot.color || dot.depth === undefined ? dot.opacity : dot.opacity * dot.depth, }; const key = `${layer}-${index}`; if (dot.d) { return ( ); } return ( ); }; return ( {frame.eyes.map((eye, index) => ( ))} {frame.notch ? ( ) : null} {frame.arcs.map((arc) => ( {arc.grad.stops.map((color, index) => ( ))} ))} {frame.arcs.map((arc) => ( ))} {frame.dotsBehind ? ( {frame.dots.map((dot, index) => renderDot(dot, index, "back"))} ) : null} {!frame.dotsBehind ? ( {frame.dots.map((dot, index) => renderDot(dot, index, "front"))} ) : null} {frame.notif ? ( ) : null} {frame.arcs.map((arc) => ( ))} ); }