* Align Oo avatar eye spacing with evoo-logo overlap ratio 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> * Format brand-lock spacing test for Biome Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * Align mobile Expo packages for install --check in CI Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * ci: retrigger after flaky chart-links E2E Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
224 lines
6.1 KiB
TypeScript
224 lines
6.1 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,
|
|
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 = OO_RIG_FACE_VIEWBOX;
|
|
/** 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<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 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 (
|
|
<Path
|
|
key={key}
|
|
d={frame.mouth.d}
|
|
fill={frame.mouth.filled ? mouthColor : "none"}
|
|
stroke={mouthColor}
|
|
strokeWidth={3.2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
);
|
|
}
|
|
if (part === "tongue") {
|
|
return frame.tongue ? <Path key={key} d={frame.tongue} fill="#FF7FA6" /> : 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 (
|
|
<G key={key} transform={state?.transform ?? shape.transform} opacity={state?.opacity}>
|
|
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
|
|
</G>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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<OoLife | null>(null);
|
|
const [frame, setFrame] = useState<OoFrame | null>(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 (
|
|
<Svg width={size} height={size} viewBox={focus === "face" ? FACE_VIEWBOX : VIEWBOX}>
|
|
<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, shown, rig.mouthColor))}
|
|
</Svg>
|
|
);
|
|
}
|