import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core"; import { buildOoRig, createOoLife, OO_LIST_MOMENTS, OO_RIG_INSET, OO_RIG_SIZE, OO_STILL_TIME, ooFrame, ooLodForSize, ooSeededRandom, } from "@rakazo/core"; import type { ReactNode } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useReducedMotion } from "react-native-reanimated"; import Svg, { Circle, Defs, Ellipse, G, LinearGradient, Path, RadialGradient, Rect, Stop, } from "react-native-svg"; const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`; const FACE_VIEWBOX = "47 62 112 112"; /** Native frames redraw the SVG tree, so they run at half the display rate. */ const FRAME_SECONDS = 1 / 30; /** Above this size an Oo always moves; smaller ones only for list moments. */ const LIVELY_SIZE = 56; 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 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 frame.tongue ? : null; } const state = fx ? frame.fx(fx.kind, fx.index) : part && part !== "fx" ? frame.parts[part] : undefined; if (state?.hidden ?? shape.hidden) return null; return ( {shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))} ); } } } export function OoFigure({ avatar, color, fallbackShape, moment, size, still = false, focus = "body", }: { avatar: OoAvatar; color: string; fallbackShape: number; moment: OoMoment; size: number; still?: boolean; /** `face` crops to the eyes, for pickers of eye styles and eyewear. */ focus?: "body" | "face"; }) { 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], ); const reducedMotion = useReducedMotion(); const lively = !still && !reducedMotion && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment)); const lifeRef = useRef(null); const [frame, setFrame] = useState(null); useEffect(() => { setFrame(null); if (!lively) return; if (!lifeRef.current) lifeRef.current = createOoLife(); const life = lifeRef.current; const start = performance.now() / 1000 - Math.random() * 10; let last = 0; return subscribe((now) => { if (now - last < FRAME_SECONDS) return; last = now; setFrame(ooFrame({ rig, avatar, moment, t: now - start, life })); }); }, [rig, avatar, moment, lively]); const stillFrame = useMemo( () => ooFrame({ rig, avatar, moment, t: OO_STILL_TIME, life: createOoLife(ooSeededRandom(1)), still: true, }), [rig, avatar, moment], ); const shown = frame ?? stillFrame; const { colors } = rig; return ( {rig.shapes.map((shape, index) => draw(shape, index, gradient, shown, rig.mouthColor))} ); }