260 lines
6.9 KiB
TypeScript
260 lines
6.9 KiB
TypeScript
"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<BotEngine | null>(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<BotFrame>(() =>
|
|
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 (
|
|
<path
|
|
{...common}
|
|
d={dot.d}
|
|
key={key}
|
|
transform={`translate(${dot.x} ${dot.y}) rotate(${dot.rot ?? 0}) scale(${RAYON})`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<circle {...common} cx={dot.x} cy={dot.y} key={key} r={dot.r} />
|
|
);
|
|
};
|
|
|
|
return (
|
|
<svg
|
|
aria-label={label}
|
|
className={cn(
|
|
"block shrink-0 text-[#0a0a0c] dark:text-[#3ecf8e]",
|
|
surface === "page"
|
|
? "[--bloub-paper:#f4f4f5] dark:[--bloub-paper:#08090b]"
|
|
: "[--bloub-paper:#ffffff] dark:[--bloub-paper:#09090b]",
|
|
className
|
|
)}
|
|
height={size}
|
|
role="img"
|
|
viewBox={`${-DEMI_VIEWBOX} ${-DEMI_VIEWBOX} ${DEMI_VIEWBOX * 2} ${DEMI_VIEWBOX * 2}`}
|
|
width={size}
|
|
>
|
|
<defs>
|
|
<mask
|
|
height={DEMI_VIEWBOX * 2}
|
|
id={maskId}
|
|
maskUnits="userSpaceOnUse"
|
|
width={DEMI_VIEWBOX * 2}
|
|
x={-DEMI_VIEWBOX}
|
|
y={-DEMI_VIEWBOX}
|
|
>
|
|
<path d={frame.bodyPath} fill="#fff" />
|
|
{frame.eyes.map((eye, index) => (
|
|
<path
|
|
d={eye.d}
|
|
fill="#000"
|
|
key={`eye-${index}`}
|
|
opacity={eye.alpha}
|
|
transform={eye.matrix}
|
|
/>
|
|
))}
|
|
{frame.notch ? (
|
|
<circle
|
|
cx={frame.notch.x}
|
|
cy={frame.notch.y}
|
|
fill="#000"
|
|
r={frame.notch.r}
|
|
/>
|
|
) : null}
|
|
</mask>
|
|
|
|
{frame.arcs.map((arc) => (
|
|
<linearGradient
|
|
gradientUnits="userSpaceOnUse"
|
|
id={`${uid}-${arc.id}`}
|
|
key={`gradient-${arc.id}`}
|
|
x1={arc.grad.x1}
|
|
x2={arc.grad.x2}
|
|
y1={arc.grad.y1}
|
|
y2={arc.grad.y2}
|
|
>
|
|
{arc.grad.stops.map((color, index) => (
|
|
<stop
|
|
key={`${arc.id}-${color}-${index}`}
|
|
offset={index / (arc.grad.stops.length - 1)}
|
|
stopColor={color}
|
|
/>
|
|
))}
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
|
|
<g fill="none" strokeLinecap="round">
|
|
{frame.arcs.map((arc) => (
|
|
<path
|
|
d={arc.back}
|
|
key={`arc-back-${arc.id}`}
|
|
opacity={arc.opacity}
|
|
stroke={`url(#${uid}-${arc.id})`}
|
|
strokeWidth={arc.width}
|
|
/>
|
|
))}
|
|
</g>
|
|
|
|
{frame.dotsBehind ? (
|
|
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "back"))}</g>
|
|
) : null}
|
|
|
|
<g opacity={frame.bodyAlpha}>
|
|
<path d={frame.bodyPath} fill="var(--bloub-paper)" />
|
|
<g mask={`url(#${maskId})`}>
|
|
<rect
|
|
fill="currentColor"
|
|
height={DEMI_VIEWBOX * 2}
|
|
width={DEMI_VIEWBOX * 2}
|
|
x={-DEMI_VIEWBOX}
|
|
y={-DEMI_VIEWBOX}
|
|
/>
|
|
</g>
|
|
</g>
|
|
|
|
{!frame.dotsBehind ? (
|
|
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "front"))}</g>
|
|
) : null}
|
|
|
|
{frame.notif ? (
|
|
<circle
|
|
cx={frame.notif.x}
|
|
cy={frame.notif.y}
|
|
fill={NOTIF_BLUE}
|
|
r={frame.notif.r}
|
|
/>
|
|
) : null}
|
|
|
|
<g fill="none" strokeLinecap="round">
|
|
{frame.arcs.map((arc) => (
|
|
<path
|
|
d={arc.front}
|
|
key={`arc-front-${arc.id}`}
|
|
opacity={arc.opacity}
|
|
stroke={`url(#${uid}-${arc.id})`}
|
|
strokeWidth={arc.width}
|
|
/>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|