import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core"; import { buildOoRig, createOoLife, OO_LIST_MOMENTS, OO_RIG_FACE_VIEWBOX, OO_RIG_INSET, OO_RIG_SIZE, OO_STILL_TIME, ooBoop, ooFrame, ooLodForSize, ooSeededRandom, } from "@rakazo/core"; import type { PointerEvent, ReactNode } from "react"; import { useEffect, useId, useMemo, useRef } from "react"; export interface OoFigureProps { avatar: OoAvatar; /** Resolved body colour (`#RRGGBB`). */ color: string; /** Classic silhouette when the avatar names none. */ fallbackShape: number; moment: OoMoment; size: number; /** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */ still?: boolean; /** Eyes follow the pointer and a tap boops the Oo. */ interactive?: boolean; /** `face` crops to the eyes, for pickers of eye styles and eyewear. */ focus?: "body" | "face"; className?: string; } /** The body fills the avatar box; ears, hats and effects may reach past it. */ const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`; const OO_FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX; /** Above this size an Oo always moves; smaller ones only for list moments. */ const LIVELY_SIZE = 56; /* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */ type Tick = (now: number) => void; const ticks = new Set(); let frameRequest = 0; function loop(now: number) { for (const tick of ticks) tick(now / 1000); frameRequest = ticks.size ? requestAnimationFrame(loop) : 0; } function subscribe(tick: Tick): () => void { ticks.add(tick); if (!frameRequest) frameRequest = requestAnimationFrame(loop); return () => { ticks.delete(tick); }; } function prefersReducedMotion(): boolean { return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; } /* ---------- static drawing ---------- */ function paintProps(paint: OoPaint, gradient: string) { const fill = paint.fill === "$body" ? `url(#${gradient}-body)` : paint.fill === "$shine" ? `url(#${gradient}-shine)` : paint.fill; return { fill: fill ?? (paint.stroke ? "none" : undefined), stroke: paint.stroke, strokeWidth: paint.strokeWidth, opacity: paint.opacity, fillOpacity: paint.fillOpacity, strokeOpacity: paint.strokeOpacity, strokeLinecap: paint.round ? ("round" as const) : undefined, strokeLinejoin: paint.round ? ("round" as const) : undefined, strokeDasharray: paint.dash, }; } function draw( shape: OoShape, key: number, gradient: string, frame: OoFrame, mouthColor: string, ): ReactNode { switch (shape.type) { case "path": return ; case "circle": return ( ); case "ellipse": return ( ); case "rect": return ( ); case "group": { const { part, fx } = shape; if (part === "mouth") { return ( ); } if (part === "tongue") { return ( ); } const state = fx ? frame.fx(fx.kind, fx.index) : part && part !== "fx" ? frame.parts[part] : undefined; const hidden = state?.hidden ?? shape.hidden; return ( {shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))} ); } } } /* ---------- live updates ---------- */ function applyFrame(svg: SVGSVGElement, frame: OoFrame, mouthColor: string) { for (const node of svg.querySelectorAll("g[data-part]")) { const part = node.dataset.part; if (part === "mouth") { const path = node.firstElementChild; path?.setAttribute("d", frame.mouth.d); path?.setAttribute("fill", frame.mouth.filled ? mouthColor : "none"); continue; } if (part === "tongue") { node.firstElementChild?.setAttribute("d", frame.tongue); continue; } const fxKind = node.dataset.fx; const state = part === "fx" && fxKind ? frame.fx(fxKind as Parameters[0], Number(node.dataset.fxIndex)) : frame.parts[part as keyof OoFrame["parts"]]; if (!state) continue; if (state.transform !== undefined) node.setAttribute("transform", state.transform); if (state.opacity !== undefined) node.setAttribute("opacity", String(state.opacity)); if (state.hidden !== undefined) { if (state.hidden) node.setAttribute("display", "none"); else node.removeAttribute("display"); } } } /** One Oo, drawn from the shared rig and kept alive by the shared ticker while visible. */ export function OoFigure({ avatar, color, fallbackShape, moment, size, still = false, interactive = false, focus = "body", className, }: OoFigureProps) { const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`; const lod = ooLodForSize(size); const rig = useMemo( () => buildOoRig({ avatar, color, fallbackShape, moment, lod }), [avatar, color, fallbackShape, moment, lod], ); // The first paint is a still frame, so server renders and screenshots are stable. const stillFrame = useMemo( () => ooFrame({ rig, avatar, moment, t: OO_STILL_TIME, life: createOoLife(ooSeededRandom(1)), still: true, }), [rig, avatar, moment], ); const svgRef = useRef(null); const lifeRef = useRef(null); const startRef = useRef(0); // Small Oos in lists only move for the moments people watch for. const lively = !still && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment)); useEffect(() => { const svg = svgRef.current; if (!svg || !lively || prefersReducedMotion() || typeof requestAnimationFrame !== "function") return; if (!lifeRef.current) { lifeRef.current = createOoLife(); startRef.current = performance.now() / 1000 - Math.random() * 10; } const life = lifeRef.current; const start = startRef.current; const tick = (now: number) => applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor); let unsubscribe: (() => void) | null = null; if (typeof IntersectionObserver !== "function") { unsubscribe = subscribe(tick); return () => unsubscribe?.(); } const observer = new IntersectionObserver(([entry]) => { if (entry?.isIntersecting) unsubscribe ??= subscribe(tick); else { unsubscribe?.(); unsubscribe = null; } }); observer.observe(svg); return () => { observer.disconnect(); unsubscribe?.(); }; }, [rig, avatar, moment, lively]); const pointerHandlers = interactive ? { onPointerMove: (event: PointerEvent) => { const life = lifeRef.current; if (!life || lod !== "full") return; const box = event.currentTarget.getBoundingClientRect(); const x = ((event.clientX - box.left) / box.width) * 2 - 1; const y = ((event.clientY - box.top) / box.height) * 2 - 1; life.pointer = { x: Math.max(-1, Math.min(1, x)), y: Math.max(-1, Math.min(1, y)) }; }, onPointerLeave: () => { if (lifeRef.current) lifeRef.current.pointer = null; }, onPointerDown: () => { if (lifeRef.current) ooBoop(lifeRef.current, performance.now() / 1000 - startRef.current); }, } : {}; const { colors } = rig; return ( ); }