Derive small-eye placement from evoo-logo.svg center spacing (~0.94 of r_big + r_small) for the shared rig, EvooMascot brand lock, and face crops. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
323 lines
9.7 KiB
TypeScript
323 lines
9.7 KiB
TypeScript
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<Tick>();
|
|
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 <path key={key} d={shape.d} {...paintProps(shape, gradient)} />;
|
|
case "circle":
|
|
return (
|
|
<circle
|
|
key={key}
|
|
cx={shape.cx}
|
|
cy={shape.cy}
|
|
r={shape.r}
|
|
{...paintProps(shape, gradient)}
|
|
/>
|
|
);
|
|
case "ellipse":
|
|
return (
|
|
<ellipse
|
|
key={key}
|
|
cx={shape.cx}
|
|
cy={shape.cy}
|
|
rx={shape.rx}
|
|
ry={shape.ry}
|
|
{...paintProps(shape, gradient)}
|
|
/>
|
|
);
|
|
case "rect":
|
|
return (
|
|
<rect
|
|
key={key}
|
|
x={shape.x}
|
|
y={shape.y}
|
|
width={shape.width}
|
|
height={shape.height}
|
|
rx={shape.rx}
|
|
{...paintProps(shape, gradient)}
|
|
/>
|
|
);
|
|
case "group": {
|
|
const { part, fx } = shape;
|
|
if (part === "mouth") {
|
|
return (
|
|
<g key={key} data-part="mouth">
|
|
<path
|
|
d={frame.mouth.d}
|
|
fill={frame.mouth.filled ? mouthColor : "none"}
|
|
stroke={mouthColor}
|
|
strokeWidth={3.2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</g>
|
|
);
|
|
}
|
|
if (part === "tongue") {
|
|
return (
|
|
<g key={key} data-part="tongue">
|
|
<path d={frame.tongue} fill="#FF7FA6" />
|
|
</g>
|
|
);
|
|
}
|
|
const state = fx
|
|
? frame.fx(fx.kind, fx.index)
|
|
: part && part !== "fx"
|
|
? frame.parts[part]
|
|
: undefined;
|
|
const hidden = state?.hidden ?? shape.hidden;
|
|
return (
|
|
<g
|
|
key={key}
|
|
data-part={part}
|
|
data-fx={fx?.kind}
|
|
data-fx-index={fx?.index}
|
|
transform={state?.transform ?? shape.transform}
|
|
opacity={state?.opacity}
|
|
display={hidden ? "none" : undefined}
|
|
>
|
|
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
|
|
</g>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- live updates ---------- */
|
|
|
|
function applyFrame(svg: SVGSVGElement, frame: OoFrame, mouthColor: string) {
|
|
for (const node of svg.querySelectorAll<SVGGElement>("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<OoFrame["fx"]>[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<SVGSVGElement>(null);
|
|
const lifeRef = useRef<OoLife | null>(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<SVGSVGElement>) => {
|
|
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 (
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={focus === "face" ? OO_FACE_VIEWBOX : OO_VIEWBOX}
|
|
width={size}
|
|
height={size}
|
|
aria-hidden="true"
|
|
className={className}
|
|
data-oo-species={avatar.species}
|
|
data-oo-moment={moment}
|
|
overflow={focus === "face" ? "hidden" : "visible"}
|
|
{...pointerHandlers}
|
|
>
|
|
<defs>
|
|
<linearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0" stopColor={colors.light} />
|
|
<stop offset="0.45" stopColor={colors.base} />
|
|
<stop offset="1" stopColor={colors.dark} />
|
|
</linearGradient>
|
|
<radialGradient id={`${gradient}-shine`} cx="0.3" cy="0.22" r="0.55">
|
|
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.55" />
|
|
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
|
|
</radialGradient>
|
|
</defs>
|
|
{rig.shapes.map((shape, index) => draw(shape, index, gradient, stillFrame, rig.mouthColor))}
|
|
</svg>
|
|
);
|
|
}
|