* 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>
1550 lines
42 KiB
TypeScript
1550 lines
42 KiB
TypeScript
/**
|
||
* Oo avatar rig: the drawing of an Oo as plain shape data in a 200×200 box.
|
||
*
|
||
* Web (SVG) and mobile (react-native-svg) both render this tree, so every species,
|
||
* accessory and prop exists exactly once. Parts named with `part` are the ones the
|
||
* motion layer (`oo-motion.ts`) moves each frame.
|
||
*/
|
||
import type { OoAvatar, OoEyewear, OoHeadwear, OoNeckwear, OoSpecies } from "./oo-avatar.js";
|
||
import { OO_RIG_EYES, ooLensRadius } from "./oo-avatar.js";
|
||
import { OO_CLASSIC_SHAPE_KEYS, OO_CLASSIC_SHAPES } from "./oo-palette.js";
|
||
|
||
export const OO_RIG_SIZE = 200;
|
||
/** Margin around the body that renderers crop away so the Oo fills its avatar box. */
|
||
export const OO_RIG_INSET = 20;
|
||
|
||
export const OO_MOMENTS = [
|
||
"idle",
|
||
"greeting",
|
||
"listening",
|
||
"speaking",
|
||
"thinking",
|
||
"working",
|
||
"reading",
|
||
"waiting",
|
||
"offering",
|
||
"cooking",
|
||
"autopilot",
|
||
"success",
|
||
"concerned",
|
||
"asleep",
|
||
] as const;
|
||
export type OoMoment = (typeof OO_MOMENTS)[number];
|
||
|
||
/**
|
||
* Level of detail. Small avatars drop arms, props and effects so they stay readable:
|
||
* `xs` up to 28 px, `mid` up to 56 px, `full` above.
|
||
*/
|
||
export type OoLod = "xs" | "mid" | "full";
|
||
|
||
export function ooLodForSize(px: number): OoLod {
|
||
if (px <= 28) return "xs";
|
||
if (px <= 56) return "mid";
|
||
return "full";
|
||
}
|
||
|
||
export type OoPart =
|
||
| "rig"
|
||
| "shadow"
|
||
| "eye-big"
|
||
| "eye-small"
|
||
| "pupil-big"
|
||
| "pupil-small"
|
||
| "closed-big"
|
||
| "closed-small"
|
||
| "happy-big"
|
||
| "happy-small"
|
||
| "brow-big"
|
||
| "brow-small"
|
||
| "mouth"
|
||
| "tongue"
|
||
| "arm-left"
|
||
| "arm-right"
|
||
| "hand-right"
|
||
| "tail"
|
||
| "ear-left"
|
||
| "ear-right"
|
||
| "bulb"
|
||
| "fx";
|
||
|
||
export type OoFxKind =
|
||
| "dot"
|
||
| "zzz"
|
||
| "spark"
|
||
| "sweat"
|
||
| "wave"
|
||
| "bubble"
|
||
| "bubble-dot"
|
||
| "steam"
|
||
| "moon"
|
||
| "star";
|
||
|
||
export interface OoPaint {
|
||
/** A colour, `none`, or `$body` / `$shine` for the renderer's body gradients. */
|
||
fill?: string;
|
||
stroke?: string;
|
||
strokeWidth?: number;
|
||
opacity?: number;
|
||
fillOpacity?: number;
|
||
strokeOpacity?: number;
|
||
round?: boolean;
|
||
dash?: string;
|
||
}
|
||
|
||
export type OoShape =
|
||
| ({ type: "path"; d: string } & OoPaint)
|
||
| ({ type: "circle"; cx: number; cy: number; r: number } & OoPaint)
|
||
| ({ type: "ellipse"; cx: number; cy: number; rx: number; ry: number } & OoPaint)
|
||
| ({ type: "rect"; x: number; y: number; width: number; height: number; rx?: number } & OoPaint)
|
||
| OoGroup;
|
||
|
||
export interface OoGroup {
|
||
type: "group";
|
||
part?: OoPart;
|
||
/** Rotation pivot for ears and tails. */
|
||
pivot?: readonly [number, number];
|
||
fx?: { kind: OoFxKind; index: number };
|
||
transform?: string;
|
||
hidden?: boolean;
|
||
children: OoShape[];
|
||
}
|
||
|
||
export interface OoBodyColors {
|
||
base: string;
|
||
dark: string;
|
||
light: string;
|
||
edge: string;
|
||
}
|
||
|
||
export interface OoRig {
|
||
shapes: OoShape[];
|
||
colors: OoBodyColors;
|
||
mouthColor: string;
|
||
/** Species tweaks the motion layer needs. */
|
||
mouthStyle: "smile" | "cat" | "wide";
|
||
floatScale: number;
|
||
}
|
||
|
||
const INK = "#2A2233";
|
||
const PINK = "#FF8FB5";
|
||
const ROBOT_GREY = "#8A93A6";
|
||
|
||
/* ---------- small helpers ---------- */
|
||
|
||
function hexToRgb(hex: string): [number, number, number] {
|
||
let clean = hex.replace("#", "");
|
||
if (clean.length === 3)
|
||
clean = clean
|
||
.split("")
|
||
.map((c) => c + c)
|
||
.join("");
|
||
const n = Number.parseInt(clean, 16) || 0;
|
||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||
}
|
||
|
||
export function ooMix(a: string, b: string, t: number): string {
|
||
const A = hexToRgb(a);
|
||
const B = hexToRgb(b);
|
||
const channel = (i: number) =>
|
||
Math.round((A[i] ?? 0) + ((B[i] ?? 0) - (A[i] ?? 0)) * t)
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
return `#${channel(0)}${channel(1)}${channel(2)}`.toUpperCase();
|
||
}
|
||
|
||
function brightness(hex: string): number {
|
||
const [r, g, b] = hexToRgb(hex);
|
||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||
}
|
||
|
||
export function ooBodyColors(base: string): OoBodyColors {
|
||
const nearWhite = brightness(base) > 235;
|
||
return {
|
||
base,
|
||
dark: ooMix(base, "#2A1E3A", 0.22),
|
||
light: ooMix(base, "#FFFFFF", 0.45),
|
||
edge: nearWhite ? "#CFC6DA" : ooMix(base, "#2A1E3A", 0.3),
|
||
};
|
||
}
|
||
|
||
function superellipse(cx: number, cy: number, rx: number, ry: number, n: number): string {
|
||
let d = "";
|
||
for (let i = 0; i < 80; i++) {
|
||
const a = (i / 80) * Math.PI * 2;
|
||
const c = Math.cos(a);
|
||
const s = Math.sin(a);
|
||
const x = cx + rx * Math.sign(c) * Math.abs(c) ** (2 / n);
|
||
const y = cy + ry * Math.sign(s) * Math.abs(s) ** (2 / n);
|
||
d += `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
|
||
}
|
||
return `${d}Z`;
|
||
}
|
||
|
||
export function ooStarPath(x: number, y: number, s: number): string {
|
||
return `M${x} ${y - s}Q${x} ${y} ${x + s} ${y}Q${x} ${y} ${x} ${y + s}Q${x} ${y} ${x - s} ${y}Q${x} ${y} ${x} ${y - s}Z`;
|
||
}
|
||
|
||
export function ooHeartPath(x: number, y: number, s: number): string {
|
||
const f = (v: number) => Number(v.toFixed(2));
|
||
return `M${f(x)} ${f(y + s * 0.9)}C${f(x - s * 1.6)} ${f(y - s * 0.1)} ${f(x - s * 0.7)} ${f(y - s * 1.25)} ${f(x)} ${f(y - s * 0.4)}C${f(x + s * 0.7)} ${f(y - s * 1.25)} ${f(x + s * 1.6)} ${f(y - s * 0.1)} ${f(x)} ${f(y + s * 0.9)}Z`;
|
||
}
|
||
|
||
const g = (children: OoShape[], extra: Omit<OoGroup, "type" | "children"> = {}): OoGroup => ({
|
||
type: "group",
|
||
children,
|
||
...extra,
|
||
});
|
||
|
||
/* ---------- species ---------- */
|
||
|
||
interface SpeciesDef {
|
||
/** Head anchor: where hats sit. */
|
||
top: number;
|
||
body: (shape: number) => { d: string; transform?: string; top?: number };
|
||
back?: (c: OoBodyColors) => OoShape[];
|
||
front?: (c: OoBodyColors) => OoShape[];
|
||
over?: (c: OoBodyColors) => OoShape[];
|
||
feet?: boolean;
|
||
feetColor?: string;
|
||
mouth?: "cat" | "wide";
|
||
mouthColor?: string;
|
||
bodyOpacity?: number;
|
||
float?: number;
|
||
}
|
||
|
||
/** Classic silhouettes live in a 228.5 box; this places them where the rig body sits. */
|
||
const CLASSIC_SCALE = 0.53;
|
||
const CLASSIC_TOPS: Record<string, number> = { tablet: 40, wedge: 6, cloud: 22, teardrop: 0 };
|
||
|
||
const SPECIES: Record<OoSpecies, SpeciesDef> = {
|
||
classic: {
|
||
top: 58,
|
||
body: (shape) => {
|
||
const key = OO_CLASSIC_SHAPE_KEYS[shape] ?? "hex";
|
||
return {
|
||
d: OO_CLASSIC_SHAPES[key] ?? "",
|
||
transform: `translate(39.4 53.4) scale(${CLASSIC_SCALE})`,
|
||
top: 53.4 + (CLASSIC_TOPS[key] ?? 0) * CLASSIC_SCALE + 4,
|
||
};
|
||
},
|
||
},
|
||
cat: {
|
||
top: 66,
|
||
mouth: "cat",
|
||
body: () => ({ d: superellipse(100, 117, 59, 52, 2.6) }),
|
||
back: (c) => [
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M52 88L57 44Q59 38 64 42L90 68Z",
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.5,
|
||
},
|
||
{ type: "path", d: "M60 78L61 53L79 69Z", fill: PINK, opacity: 0.85 },
|
||
],
|
||
{ part: "ear-left", pivot: [70, 74] },
|
||
),
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M148 88L143 44Q141 38 136 42L110 68Z",
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.5,
|
||
},
|
||
{ type: "path", d: "M140 78L139 53L121 69Z", fill: PINK, opacity: 0.85 },
|
||
],
|
||
{ part: "ear-right", pivot: [130, 74] },
|
||
),
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M146 154Q180 152 174 122Q171 108 181 102",
|
||
fill: "none",
|
||
stroke: c.dark,
|
||
strokeWidth: 10,
|
||
round: true,
|
||
},
|
||
],
|
||
{ part: "tail", pivot: [148, 154] },
|
||
),
|
||
],
|
||
over: () => [
|
||
{ type: "path", d: "M100 131L106 131L103 134.5Z", fill: PINK },
|
||
{
|
||
type: "path",
|
||
d: "M58 131L38 127M58 137L38 139M148 133L166 129M148 139L166 141",
|
||
stroke: INK,
|
||
strokeOpacity: 0.35,
|
||
strokeWidth: 1.6,
|
||
round: true,
|
||
},
|
||
],
|
||
},
|
||
dog: {
|
||
top: 64,
|
||
body: () => ({ d: superellipse(100, 117, 60, 52, 2.4) }),
|
||
back: (c) => [
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M148 142Q172 132 170 112",
|
||
fill: "none",
|
||
stroke: c.dark,
|
||
strokeWidth: 10,
|
||
round: true,
|
||
},
|
||
],
|
||
{ part: "tail", pivot: [150, 142] },
|
||
),
|
||
],
|
||
over: (c) => [
|
||
g([{ type: "path", d: "M50 74Q30 78 32 112Q34 128 48 122Q56 102 58 80Z", fill: c.dark }], {
|
||
part: "ear-left",
|
||
pivot: [56, 76],
|
||
}),
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M150 74Q170 78 168 112Q166 128 152 122Q144 102 142 80Z",
|
||
fill: c.dark,
|
||
},
|
||
],
|
||
{
|
||
part: "ear-right",
|
||
pivot: [144, 76],
|
||
},
|
||
),
|
||
{ type: "ellipse", cx: 103, cy: 130, rx: 5.5, ry: 3.8, fill: INK },
|
||
{ type: "ellipse", cx: 101.5, cy: 128.8, rx: 1.6, ry: 1, fill: "#FFFFFF", opacity: 0.7 },
|
||
],
|
||
},
|
||
bunny: {
|
||
top: 70,
|
||
body: () => ({ d: superellipse(100, 119, 56, 50, 2.4) }),
|
||
back: (c) => [
|
||
g(
|
||
[
|
||
g(
|
||
[
|
||
{
|
||
type: "ellipse",
|
||
cx: 80,
|
||
cy: 42,
|
||
rx: 11,
|
||
ry: 31,
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.5,
|
||
},
|
||
],
|
||
{
|
||
transform: "rotate(-10 80 42)",
|
||
},
|
||
),
|
||
g([{ type: "ellipse", cx: 80, cy: 44, rx: 5, ry: 22, fill: PINK, opacity: 0.8 }], {
|
||
transform: "rotate(-10 80 44)",
|
||
}),
|
||
],
|
||
{ part: "ear-left", pivot: [80, 72] },
|
||
),
|
||
g(
|
||
[
|
||
g(
|
||
[
|
||
{
|
||
type: "ellipse",
|
||
cx: 120,
|
||
cy: 42,
|
||
rx: 11,
|
||
ry: 31,
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.5,
|
||
},
|
||
],
|
||
{
|
||
transform: "rotate(10 120 42)",
|
||
},
|
||
),
|
||
g([{ type: "ellipse", cx: 120, cy: 44, rx: 5, ry: 22, fill: PINK, opacity: 0.8 }], {
|
||
transform: "rotate(10 120 44)",
|
||
}),
|
||
],
|
||
{ part: "ear-right", pivot: [120, 72] },
|
||
),
|
||
g(
|
||
[
|
||
{
|
||
type: "circle",
|
||
cx: 154,
|
||
cy: 150,
|
||
r: 10,
|
||
fill: c.light,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.2,
|
||
},
|
||
],
|
||
{
|
||
part: "tail",
|
||
pivot: [152, 150],
|
||
},
|
||
),
|
||
],
|
||
over: () => [{ type: "path", d: "M100 131L106 131L103 134Z", fill: PINK }],
|
||
},
|
||
bear: {
|
||
top: 62,
|
||
body: () => ({ d: superellipse(100, 116, 61, 55, 2.3) }),
|
||
back: (c) => [
|
||
g(
|
||
[
|
||
{ type: "circle", cx: 58, cy: 68, r: 16, fill: c.base, stroke: c.edge, strokeWidth: 1.5 },
|
||
{ type: "circle", cx: 58, cy: 68, r: 8, fill: c.light },
|
||
],
|
||
{ part: "ear-left", pivot: [60, 70] },
|
||
),
|
||
g(
|
||
[
|
||
{
|
||
type: "circle",
|
||
cx: 142,
|
||
cy: 68,
|
||
r: 16,
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.5,
|
||
},
|
||
{ type: "circle", cx: 142, cy: 68, r: 8, fill: c.light },
|
||
],
|
||
{ part: "ear-right", pivot: [140, 70] },
|
||
),
|
||
],
|
||
front: (c) => [
|
||
{ type: "ellipse", cx: 103, cy: 137, rx: 19, ry: 13, fill: c.light, opacity: 0.9 },
|
||
],
|
||
over: () => [{ type: "ellipse", cx: 103, cy: 129.5, rx: 5, ry: 3.5, fill: INK }],
|
||
},
|
||
robot: {
|
||
top: 62,
|
||
mouthColor: "#7FF3E0",
|
||
body: () => ({
|
||
d: "M68 62H132Q158 62 158 88V140Q158 166 132 166H68Q42 166 42 140V88Q42 62 68 62Z",
|
||
}),
|
||
back: () => [
|
||
{ type: "path", d: "M100 62V40", stroke: ROBOT_GREY, strokeWidth: 3.5, round: true },
|
||
g([{ type: "circle", cx: 100, cy: 35, r: 6.5, fill: "#FFD25A" }], { part: "bulb" }),
|
||
{ type: "rect", x: 33, y: 100, width: 11, height: 24, rx: 4, fill: ROBOT_GREY },
|
||
{ type: "rect", x: 156, y: 100, width: 11, height: 24, rx: 4, fill: ROBOT_GREY },
|
||
],
|
||
front: () => [
|
||
{ type: "rect", x: 52, y: 80, width: 96, height: 74, rx: 22, fill: "#1E1A2B", opacity: 0.9 },
|
||
],
|
||
},
|
||
ghost: {
|
||
top: 60,
|
||
feet: false,
|
||
float: 1.6,
|
||
bodyOpacity: 0.93,
|
||
body: () => ({
|
||
d: "M42 122Q42 60 100 60Q158 60 158 122L158 160Q148.5 171 139 160Q129.5 149 120 160Q110.5 171 101 160Q91.5 149 82 160Q72.5 171 63 160Q52.5 149 42 160Z",
|
||
}),
|
||
},
|
||
frog: {
|
||
top: 70,
|
||
mouth: "wide",
|
||
body: () => ({ d: superellipse(100, 121, 64, 47, 2.2) }),
|
||
// The two bumps echo the Oo: a big one and a small one.
|
||
back: (c) => [
|
||
g(
|
||
[{ type: "circle", cx: 74, cy: 82, r: 20, fill: c.base, stroke: c.edge, strokeWidth: 1.5 }],
|
||
{
|
||
part: "ear-left",
|
||
pivot: [74, 84],
|
||
},
|
||
),
|
||
g(
|
||
[
|
||
{
|
||
type: "circle",
|
||
cx: 128,
|
||
cy: 85,
|
||
r: 15,
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.5,
|
||
},
|
||
],
|
||
{
|
||
part: "ear-right",
|
||
pivot: [128, 86],
|
||
},
|
||
),
|
||
],
|
||
},
|
||
penguin: {
|
||
top: 58,
|
||
feetColor: "#FF9F2E",
|
||
body: () => ({ d: superellipse(100, 114, 57, 57, 2.0) }),
|
||
front: () => [
|
||
{ type: "ellipse", cx: 103, cy: 114, rx: 45, ry: 36, fill: "#FFFFFF" },
|
||
{ type: "ellipse", cx: 100, cy: 150, rx: 34, ry: 17, fill: "#FFFFFF" },
|
||
],
|
||
over: () => [
|
||
{
|
||
type: "path",
|
||
d: "M96 126L110 126L103 133Z",
|
||
fill: "#FF9F2E",
|
||
stroke: "#E07E12",
|
||
strokeWidth: 1,
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
/* ---------- face ---------- */
|
||
|
||
const EYESHADOW: Record<string, string> = {
|
||
rose: "#FF8FB5",
|
||
lilac: "#B48CFF",
|
||
gold: "#F5C04A",
|
||
mint: "#5FD9B0",
|
||
};
|
||
const IRIS: Record<string, string> = {
|
||
brown: "#8A5A34",
|
||
blue: "#3D8BFF",
|
||
green: "#2DBE7E",
|
||
violet: "#8E5CF7",
|
||
};
|
||
|
||
type EyeKey = "big" | "small";
|
||
/** Outer side of each eye: the big eye looks left, the small eye right. */
|
||
const OUTER: Record<EyeKey, -1 | 1> = { big: -1, small: 1 };
|
||
export const OO_PUPIL_RATIO = 0.457;
|
||
|
||
function lashes(eye: EyeKey, style: OoAvatar["lashes"]): OoShape[] {
|
||
if (style === "none") return [];
|
||
const { cx, cy, r } = OO_RIG_EYES[eye];
|
||
const out = OUTER[eye];
|
||
const k = eye === "big" ? 1 : 0.72;
|
||
const set =
|
||
style === "soft"
|
||
? { angles: [22, 46, 70], length: 6, width: 2 }
|
||
: style === "full"
|
||
? { angles: [8, 28, 48, 68, 88], length: 7.5, width: 2.4 }
|
||
: { angles: [52, 78], length: 12, width: 2.6 };
|
||
let d = "";
|
||
for (const deg of set.angles) {
|
||
const t = (deg * Math.PI) / 180;
|
||
const t2 = ((deg + 14) * Math.PI) / 180;
|
||
const length = set.length * k;
|
||
const p = (v: number) => v.toFixed(1);
|
||
d += `M${p(cx + out * r * Math.sin(t))} ${p(cy - r * Math.cos(t))}`;
|
||
d += `Q${p(cx + out * (r + length * 0.55) * Math.sin(t))} ${p(cy - (r + length * 0.55) * Math.cos(t))} `;
|
||
d += `${p(cx + out * (r + length) * Math.sin(t2))} ${p(cy - (r + length) * Math.cos(t2))}`;
|
||
}
|
||
return [
|
||
{
|
||
type: "path",
|
||
d,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: set.width * (eye === "big" ? 1 : 0.85),
|
||
round: true,
|
||
},
|
||
];
|
||
}
|
||
|
||
function eye(key: EyeKey, avatar: OoAvatar, lod: OoLod): OoShape[] {
|
||
const { cx, cy, r } = OO_RIG_EYES[key];
|
||
const pr = r * OO_PUPIL_RATIO;
|
||
const out = OUTER[key];
|
||
const big = key === "big";
|
||
const open: OoShape[] = [];
|
||
const shadow = EYESHADOW[avatar.eyeshadow];
|
||
if (shadow)
|
||
open.push({
|
||
type: "ellipse",
|
||
cx,
|
||
cy: cy - 3,
|
||
rx: r + 4,
|
||
ry: r + 5,
|
||
fill: shadow,
|
||
opacity: 0.55,
|
||
});
|
||
open.push({
|
||
type: "circle",
|
||
cx,
|
||
cy,
|
||
r,
|
||
fill: "#FFFFFF",
|
||
stroke: INK,
|
||
strokeOpacity: 0.3,
|
||
strokeWidth: 1.6,
|
||
});
|
||
const pupil: OoShape[] = [];
|
||
const iris = IRIS[avatar.iris];
|
||
if (iris) {
|
||
pupil.push({
|
||
type: "circle",
|
||
cx,
|
||
cy,
|
||
r: pr * 1.3,
|
||
fill: iris,
|
||
stroke: INK,
|
||
strokeOpacity: 0.35,
|
||
strokeWidth: 1.2,
|
||
});
|
||
pupil.push({ type: "circle", cx, cy, r: pr * 0.72, fill: "#1B1622" });
|
||
} else {
|
||
pupil.push({ type: "circle", cx, cy, r: pr, fill: "#1B1622" });
|
||
}
|
||
const sx = cx - pr * 0.4;
|
||
const sy = cy - pr * 0.45;
|
||
const ss = pr * 0.42;
|
||
if (avatar.sparkle === "star")
|
||
pupil.push({ type: "path", d: ooStarPath(sx, sy, ss * 1.5), fill: "#FFFFFF" });
|
||
else if (avatar.sparkle === "heart")
|
||
pupil.push({ type: "path", d: ooHeartPath(sx, sy, ss * 1.05), fill: "#FFFFFF" });
|
||
else pupil.push({ type: "circle", cx: sx, cy: sy, r: ss, fill: "#FFFFFF" });
|
||
pupil.push({
|
||
type: "circle",
|
||
cx: cx + pr * 0.38,
|
||
cy: cy + pr * 0.34,
|
||
r: pr * 0.16,
|
||
fill: "#FFFFFF",
|
||
opacity: 0.9,
|
||
});
|
||
open.push(g(pupil, { part: big ? "pupil-big" : "pupil-small" }));
|
||
if (avatar.liner === "wing") {
|
||
const x0 = cx - out * r * 0.35;
|
||
const y0 = cy - r * 0.94;
|
||
const ex = cx + out * r;
|
||
const ey = cy - 1;
|
||
const w = big ? 10 : 7;
|
||
open.push({
|
||
type: "path",
|
||
d: `M${x0.toFixed(1)} ${y0.toFixed(1)}A${r.toFixed(2)} ${r.toFixed(2)} 0 0 ${out < 0 ? 0 : 1} ${ex.toFixed(1)} ${ey}L${(ex + out * w).toFixed(1)} ${(ey - w * 0.85).toFixed(1)}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: big ? 3.4 : 2.6,
|
||
round: true,
|
||
});
|
||
}
|
||
if (lod !== "xs") open.push(...lashes(key, avatar.lashes));
|
||
|
||
const closed: OoShape[] = [
|
||
{
|
||
type: "path",
|
||
d: `M${(cx - r).toFixed(1)} ${cy}Q${cx} ${(cy + r * 0.62).toFixed(1)} ${(cx + r).toFixed(1)} ${cy}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: big ? 4 : 3.2,
|
||
round: true,
|
||
},
|
||
];
|
||
if (avatar.lashes !== "none" && lod !== "xs") {
|
||
closed.push({
|
||
type: "path",
|
||
d: `M${(cx + out * r * 0.95).toFixed(1)} ${cy + 1}l${out * 5} 4M${(cx + out * r * 0.65).toFixed(1)} ${(cy + r * 0.35).toFixed(1)}l${out * 3} 5`,
|
||
stroke: INK,
|
||
strokeWidth: 2,
|
||
round: true,
|
||
});
|
||
}
|
||
const happy: OoShape[] = [
|
||
{
|
||
type: "path",
|
||
d: `M${(cx - r * 0.95).toFixed(1)} ${(cy + r * 0.25).toFixed(1)}Q${cx} ${(cy - r * 0.95).toFixed(1)} ${(cx + r * 0.95).toFixed(1)} ${(cy + r * 0.25).toFixed(1)}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: big ? 4.4 : 3.4,
|
||
round: true,
|
||
},
|
||
];
|
||
return [
|
||
g(open, { part: big ? "eye-big" : "eye-small" }),
|
||
g(closed, { part: big ? "closed-big" : "closed-small", hidden: true }),
|
||
g(happy, { part: big ? "happy-big" : "happy-small", hidden: true }),
|
||
];
|
||
}
|
||
|
||
function cheeks(avatar: OoAvatar): OoShape[] {
|
||
const cheek = avatar.cheeks === "none" && avatar.species === "frog" ? "pink" : avatar.cheeks;
|
||
if (cheek === "none") return [];
|
||
if (cheek === "freckles") {
|
||
const dots: [number, number, number][] = [
|
||
[58, 129, 1.6],
|
||
[64, 133, 1.6],
|
||
[69, 128, 1.6],
|
||
[127, 131, 1.4],
|
||
[132, 135, 1.4],
|
||
[136, 130, 1.4],
|
||
];
|
||
return dots.map(([cx, cy, r]) => ({
|
||
type: "circle",
|
||
cx,
|
||
cy,
|
||
r,
|
||
fill: "#C77B55",
|
||
opacity: 0.7,
|
||
}));
|
||
}
|
||
const fill = cheek === "peach" ? "#FFB08A" : PINK;
|
||
return [
|
||
{ type: "ellipse", cx: 62, cy: 131, rx: 9.5, ry: 5.5, fill, opacity: 0.55 },
|
||
{ type: "ellipse", cx: 130, cy: 133, rx: 7.5, ry: 4.5, fill, opacity: 0.55 },
|
||
];
|
||
}
|
||
|
||
function brows(avatar: OoAvatar): OoShape[] {
|
||
const width = avatar.brows === "bushy" ? 5.5 : 3.2;
|
||
return [
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M-12 0Q0 -3.5 12 0",
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: width,
|
||
round: true,
|
||
},
|
||
],
|
||
{
|
||
part: "brow-big",
|
||
hidden: true,
|
||
},
|
||
),
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M-8 0Q0 -2.6 8 0",
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: width * 0.85,
|
||
round: true,
|
||
},
|
||
],
|
||
{
|
||
part: "brow-small",
|
||
hidden: true,
|
||
},
|
||
),
|
||
];
|
||
}
|
||
|
||
/* ---------- accessories ---------- */
|
||
|
||
function eyewear(kind: OoEyewear): OoShape[] {
|
||
const B = OO_RIG_EYES.big;
|
||
const S = OO_RIG_EYES.small;
|
||
const lb = ooLensRadius("big");
|
||
const ls = ooLensRadius("small");
|
||
const temples = (inset: number) =>
|
||
`M${(B.cx - lb).toFixed(1)} ${B.cy - inset}L${(B.cx - lb - 10).toFixed(1)} ${B.cy - inset - 5}M${(S.cx + ls).toFixed(1)} ${(S.cy - inset).toFixed(1)}L${(S.cx + ls + 10).toFixed(1)} ${(S.cy - inset - 5).toFixed(1)}`;
|
||
const bridge = `M${(B.cx + lb).toFixed(1)} ${B.cy - 2}Q${((B.cx + lb + S.cx - ls) / 2).toFixed(1)} ${B.cy - 7} ${(S.cx - ls).toFixed(1)} ${(S.cy - 2).toFixed(1)}`;
|
||
switch (kind) {
|
||
case "round":
|
||
case "sun": {
|
||
const lens =
|
||
kind === "sun"
|
||
? { fill: "#1B1622", fillOpacity: 0.9 }
|
||
: { fill: "#FFFFFF", fillOpacity: 0.08 };
|
||
const shapes: OoShape[] = [
|
||
{ type: "circle", cx: B.cx, cy: B.cy, r: lb, ...lens, stroke: INK, strokeWidth: 3.2 },
|
||
{ type: "circle", cx: S.cx, cy: S.cy, r: ls, ...lens, stroke: INK, strokeWidth: 3.2 },
|
||
{
|
||
type: "path",
|
||
d: bridge + temples(3),
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: 3,
|
||
round: true,
|
||
},
|
||
];
|
||
if (kind === "sun") {
|
||
shapes.push({
|
||
type: "path",
|
||
d: `M${B.cx - 12} ${B.cy - 8}L${B.cx - 4} ${B.cy - 16}M${S.cx - 7} ${(S.cy - 5).toFixed(1)}L${S.cx - 2} ${(S.cy - 10).toFixed(1)}`,
|
||
stroke: "#FFFFFF",
|
||
strokeOpacity: 0.7,
|
||
strokeWidth: 2.4,
|
||
round: true,
|
||
});
|
||
}
|
||
return shapes;
|
||
}
|
||
case "nerd":
|
||
return [
|
||
{
|
||
type: "rect",
|
||
x: B.cx - lb,
|
||
y: B.cy - lb + 2,
|
||
width: lb * 2,
|
||
height: lb * 2 - 4,
|
||
rx: 10,
|
||
fill: "#FFFFFF",
|
||
fillOpacity: 0.08,
|
||
stroke: INK,
|
||
strokeWidth: 5,
|
||
},
|
||
{
|
||
type: "rect",
|
||
x: S.cx - ls,
|
||
y: S.cy - ls + 2,
|
||
width: ls * 2,
|
||
height: ls * 2 - 4,
|
||
rx: 8,
|
||
fill: "#FFFFFF",
|
||
fillOpacity: 0.08,
|
||
stroke: INK,
|
||
strokeWidth: 5,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M${(B.cx + lb).toFixed(1)} ${B.cy - 4}L${(S.cx - ls).toFixed(1)} ${(S.cy - 4).toFixed(1)}${temples(6)}`,
|
||
stroke: INK,
|
||
strokeWidth: 4,
|
||
round: true,
|
||
},
|
||
];
|
||
case "heart":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: ooHeartPath(B.cx, B.cy + 1, lb - 1),
|
||
fill: "#FF5E9C",
|
||
fillOpacity: 0.32,
|
||
stroke: "#E23B7D",
|
||
strokeWidth: 3,
|
||
round: true,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: ooHeartPath(S.cx, S.cy + 1, ls - 1),
|
||
fill: "#FF5E9C",
|
||
fillOpacity: 0.32,
|
||
stroke: "#E23B7D",
|
||
strokeWidth: 3,
|
||
round: true,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M${(B.cx + lb - 3).toFixed(1)} ${B.cy - 6}L${(S.cx - ls + 2).toFixed(1)} ${(S.cy - 6).toFixed(1)}`,
|
||
stroke: "#E23B7D",
|
||
strokeWidth: 3,
|
||
round: true,
|
||
},
|
||
];
|
||
case "monocle":
|
||
return [
|
||
{
|
||
type: "circle",
|
||
cx: B.cx,
|
||
cy: B.cy,
|
||
r: lb,
|
||
fill: "#FFFFFF",
|
||
fillOpacity: 0.1,
|
||
stroke: "#C9A040",
|
||
strokeWidth: 3.4,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M${B.cx - B.r + 2} ${B.cy + B.r}Q${B.cx - B.r - 6} ${B.cy + 44} ${B.cx - 10} ${B.cy + 58}`,
|
||
fill: "none",
|
||
stroke: "#C9A040",
|
||
strokeWidth: 1.6,
|
||
dash: "2.5 2.5",
|
||
},
|
||
];
|
||
case "reading": {
|
||
const half = (cx: number, cy: number, r: number) =>
|
||
`M${(cx - r).toFixed(1)} ${(cy + 5).toFixed(1)}A${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${(cx + r).toFixed(1)} ${(cy + 5).toFixed(1)}Z`;
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: half(B.cx, B.cy, lb - 1) + half(S.cx, S.cy, ls - 1),
|
||
fill: "#FFFFFF",
|
||
fillOpacity: 0.14,
|
||
stroke: INK,
|
||
strokeWidth: 3,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M${(B.cx + lb - 1).toFixed(1)} ${B.cy + 6}Q${(B.cx + S.cx) / 2 + 3} ${B.cy} ${(S.cx - ls + 1).toFixed(1)} ${(S.cy + 6).toFixed(1)}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: 3,
|
||
},
|
||
];
|
||
}
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function headwear(kind: OoHeadwear, t: number): OoShape[] {
|
||
switch (kind) {
|
||
case "chef":
|
||
return [
|
||
{
|
||
type: "circle",
|
||
cx: 84,
|
||
cy: t - 16,
|
||
r: 13,
|
||
fill: "#FFFFFF",
|
||
stroke: "#D9D3DE",
|
||
strokeWidth: 1.4,
|
||
},
|
||
{
|
||
type: "circle",
|
||
cx: 116,
|
||
cy: t - 16,
|
||
r: 13,
|
||
fill: "#FFFFFF",
|
||
stroke: "#D9D3DE",
|
||
strokeWidth: 1.4,
|
||
},
|
||
{
|
||
type: "circle",
|
||
cx: 100,
|
||
cy: t - 25,
|
||
r: 16,
|
||
fill: "#FFFFFF",
|
||
stroke: "#D9D3DE",
|
||
strokeWidth: 1.4,
|
||
},
|
||
{
|
||
type: "rect",
|
||
x: 78,
|
||
y: t - 14,
|
||
width: 44,
|
||
height: 17,
|
||
rx: 4,
|
||
fill: "#FFFFFF",
|
||
stroke: "#D9D3DE",
|
||
strokeWidth: 1.4,
|
||
},
|
||
];
|
||
case "crown":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M78 ${t + 2}L77 ${t - 20}L89 ${t - 9}L100 ${t - 26}L111 ${t - 9}L123 ${t - 20}L122 ${t + 2}Z`,
|
||
fill: "#FFCB45",
|
||
stroke: "#D99A12",
|
||
strokeWidth: 2,
|
||
round: true,
|
||
},
|
||
{ type: "circle", cx: 100, cy: t - 7, r: 3.6, fill: "#FF5E9C" },
|
||
{ type: "circle", cx: 87, cy: t - 4, r: 2.4, fill: "#5FD9B0" },
|
||
{ type: "circle", cx: 113, cy: t - 4, r: 2.4, fill: "#5FD9B0" },
|
||
];
|
||
case "headphones":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M42 ${t + 46}Q40 ${t - 16} 100 ${t - 16}Q160 ${t - 16} 158 ${t + 46}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeWidth: 6,
|
||
round: true,
|
||
},
|
||
{ type: "rect", x: 31, y: t + 32, width: 18, height: 30, rx: 8, fill: INK },
|
||
{ type: "rect", x: 151, y: t + 32, width: 18, height: 30, rx: 8, fill: INK },
|
||
{ type: "rect", x: 35, y: t + 37, width: 7, height: 20, rx: 3.5, fill: PINK },
|
||
{ type: "rect", x: 158, y: t + 37, width: 7, height: 20, rx: 3.5, fill: PINK },
|
||
];
|
||
case "bow":
|
||
return [
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M0 0L-17 -11Q-21 0 -17 11Z",
|
||
fill: "#FF6FA5",
|
||
stroke: "#E0457F",
|
||
strokeWidth: 1.6,
|
||
round: true,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: "M0 0L17 -11Q21 0 17 11Z",
|
||
fill: "#FF6FA5",
|
||
stroke: "#E0457F",
|
||
strokeWidth: 1.6,
|
||
round: true,
|
||
},
|
||
{ type: "circle", cx: 0, cy: 0, r: 5, fill: "#E0457F" },
|
||
],
|
||
{ transform: `translate(128 ${t + 2}) rotate(14)` },
|
||
),
|
||
];
|
||
case "beanie":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M56 ${t + 14}Q56 ${t - 30} 100 ${t - 30}Q144 ${t - 30} 144 ${t + 14}Z`,
|
||
fill: "#FF8A5B",
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M76 ${t - 24}V${t + 10}M100 ${t - 29}V${t + 10}M124 ${t - 24}V${t + 10}`,
|
||
stroke: "#E86E40",
|
||
strokeWidth: 3,
|
||
},
|
||
{ type: "rect", x: 52, y: t + 6, width: 96, height: 13, rx: 6.5, fill: "#E86E40" },
|
||
{ type: "circle", cx: 100, cy: t - 33, r: 9, fill: "#FFE1D2" },
|
||
];
|
||
case "flower": {
|
||
const petals: OoShape[] = [];
|
||
for (let i = 0; i < 5; i++) {
|
||
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||
petals.push({
|
||
type: "circle",
|
||
cx: Number((132 + Math.cos(a) * 7).toFixed(1)),
|
||
cy: Number((t + 3 + Math.sin(a) * 7).toFixed(1)),
|
||
r: 6,
|
||
fill: "#FFC1D8",
|
||
});
|
||
}
|
||
return [...petals, { type: "circle", cx: 132, cy: t + 3, r: 4.6, fill: "#FFCB45" }];
|
||
}
|
||
case "party":
|
||
return [
|
||
g(
|
||
[
|
||
{ type: "path", d: `M86 ${t + 4}L100 ${t - 34}L114 ${t + 4}Z`, fill: "#7CC8FF" },
|
||
{
|
||
type: "path",
|
||
d: `M92 ${t - 12}L108 ${t - 8}M89 ${t - 2}L111 ${t + 2}`,
|
||
stroke: "#FF6FA5",
|
||
strokeWidth: 4,
|
||
},
|
||
{ type: "circle", cx: 100, cy: t - 35, r: 5, fill: "#FFCB45" },
|
||
],
|
||
{ transform: `rotate(-12 100 ${t})` },
|
||
),
|
||
];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function neckwear(kind: OoNeckwear, y: number): OoShape[] {
|
||
switch (kind) {
|
||
case "bowtie":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M103 ${y}L88 ${y - 8}V${y + 8}ZM103 ${y}L118 ${y - 8}V${y + 8}Z`,
|
||
fill: "#E23B7D",
|
||
stroke: "#B8215D",
|
||
strokeWidth: 1.5,
|
||
round: true,
|
||
},
|
||
{ type: "circle", cx: 103, cy: y, r: 3.6, fill: "#B8215D" },
|
||
];
|
||
case "scarf":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M58 ${y - 8}Q100 ${y + 6} 146 ${y - 8}L146 ${y + 2}Q100 ${y + 16} 58 ${y + 2}Z`,
|
||
fill: "#5FB4FF",
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M122 ${y + 4}L128 ${y + 24}L138 ${y + 21}L132 ${y + 2}Z`,
|
||
fill: "#3D97FE",
|
||
},
|
||
];
|
||
case "bell":
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M70 ${y - 6}Q103 ${y + 4} 136 ${y - 6}`,
|
||
fill: "none",
|
||
stroke: "#E23B7D",
|
||
strokeWidth: 5,
|
||
round: true,
|
||
},
|
||
{
|
||
type: "circle",
|
||
cx: 103,
|
||
cy: y + 3,
|
||
r: 7,
|
||
fill: "#FFCB45",
|
||
stroke: "#D99A12",
|
||
strokeWidth: 1.5,
|
||
},
|
||
{ type: "path", d: `M99 ${y + 5}H107`, stroke: "#D99A12", strokeWidth: 1.5 },
|
||
];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function arm(style: OoAvatar["arms"], c: OoBodyColors): OoShape[] {
|
||
switch (style) {
|
||
case "glass":
|
||
return [
|
||
{
|
||
type: "rect",
|
||
x: -7,
|
||
y: -6,
|
||
width: 14,
|
||
height: 38,
|
||
rx: 7,
|
||
fill: "#FFFFFF",
|
||
fillOpacity: 0.62,
|
||
stroke: INK,
|
||
strokeOpacity: 0.2,
|
||
strokeWidth: 1.4,
|
||
},
|
||
{ type: "rect", x: -3.4, y: -1, width: 2.6, height: 25, rx: 1.3, fill: "#FFFFFF" },
|
||
];
|
||
case "stub":
|
||
return [
|
||
{
|
||
type: "rect",
|
||
x: -7,
|
||
y: -6,
|
||
width: 14,
|
||
height: 38,
|
||
rx: 7,
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.4,
|
||
},
|
||
];
|
||
case "paw":
|
||
return [
|
||
{
|
||
type: "rect",
|
||
x: -6.5,
|
||
y: -6,
|
||
width: 13,
|
||
height: 30,
|
||
rx: 6.5,
|
||
fill: c.base,
|
||
stroke: c.edge,
|
||
strokeWidth: 1.4,
|
||
},
|
||
{ type: "circle", cx: 0, cy: 27, r: 8.5, fill: c.base, stroke: c.edge, strokeWidth: 1.4 },
|
||
{ type: "ellipse", cx: 0, cy: 29, rx: 3.6, ry: 2.8, fill: PINK },
|
||
{ type: "circle", cx: -4, cy: 23.5, r: 1.5, fill: PINK },
|
||
{ type: "circle", cx: 0, cy: 22.3, r: 1.5, fill: PINK },
|
||
{ type: "circle", cx: 4, cy: 23.5, r: 1.5, fill: PINK },
|
||
];
|
||
case "robot":
|
||
return [
|
||
{
|
||
type: "rect",
|
||
x: -4,
|
||
y: -5,
|
||
width: 8,
|
||
height: 19,
|
||
rx: 3,
|
||
fill: "#A7B0C2",
|
||
stroke: "#6B7383",
|
||
strokeWidth: 1.2,
|
||
},
|
||
{ type: "circle", cx: 0, cy: 15, r: 5, fill: "#6B7383" },
|
||
{
|
||
type: "rect",
|
||
x: -4,
|
||
y: 15,
|
||
width: 8,
|
||
height: 14,
|
||
rx: 3,
|
||
fill: "#A7B0C2",
|
||
stroke: "#6B7383",
|
||
strokeWidth: 1.2,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: "M-5 30Q-7 36 -2 37M5 30Q7 36 2 37",
|
||
fill: "none",
|
||
stroke: "#6B7383",
|
||
strokeWidth: 2.2,
|
||
round: true,
|
||
},
|
||
];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/* ---------- props and effects from the moment ---------- */
|
||
|
||
function frontProp(moment: OoMoment): OoShape[] {
|
||
switch (moment) {
|
||
case "working":
|
||
case "autopilot":
|
||
// The laptop lid carries the Oo mark.
|
||
return [
|
||
{ type: "rect", x: 69, y: 138, width: 62, height: 35, rx: 6, fill: "#3A3346" },
|
||
{ type: "circle", cx: 95, cy: 155, r: 5, fill: "#FFB3CF" },
|
||
{ type: "circle", cx: 107, cy: 156, r: 5 / 1.618, fill: "#FFB3CF" },
|
||
{ type: "path", d: "M62 172H138L133 179H67Z", fill: "#5A5266" },
|
||
];
|
||
case "reading":
|
||
return [
|
||
{ type: "rect", x: 80, y: 136, width: 40, height: 44, rx: 5, fill: "#C9935A" },
|
||
{ type: "rect", x: 84, y: 142, width: 32, height: 34, rx: 2, fill: "#FFFFFF" },
|
||
{ type: "rect", x: 92, y: 132, width: 16, height: 7, rx: 2.5, fill: "#8A8F99" },
|
||
{
|
||
type: "path",
|
||
d: "M88 150H112M88 157H112M88 164H104",
|
||
stroke: "#C8C1CF",
|
||
strokeWidth: 2.4,
|
||
round: true,
|
||
},
|
||
];
|
||
case "cooking":
|
||
return [
|
||
{ type: "rect", x: 66, y: 149, width: 68, height: 6, rx: 3, fill: "#6A7282" },
|
||
{ type: "rect", x: 70, y: 153, width: 60, height: 25, rx: 9, fill: "#4A5160" },
|
||
{ type: "rect", x: 58, y: 157, width: 14, height: 5, rx: 2.5, fill: "#4A5160" },
|
||
{ type: "rect", x: 128, y: 157, width: 14, height: 5, rx: 2.5, fill: "#4A5160" },
|
||
];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function handProp(moment: OoMoment): OoShape[] {
|
||
switch (moment) {
|
||
case "offering":
|
||
return [
|
||
g(
|
||
[
|
||
{
|
||
type: "rect",
|
||
x: -2,
|
||
y: -28,
|
||
width: 44,
|
||
height: 32,
|
||
rx: 7,
|
||
fill: "#FFFFFF",
|
||
stroke: INK,
|
||
strokeOpacity: 0.18,
|
||
strokeWidth: 1.4,
|
||
},
|
||
{ type: "rect", x: 5, y: -21, width: 22, height: 4, rx: 2, fill: PINK },
|
||
{ type: "rect", x: 5, y: -13, width: 30, height: 3, rx: 1.5, fill: "#E2DBE7" },
|
||
{ type: "rect", x: 5, y: -6, width: 16, height: 7, rx: 3.5, fill: INK },
|
||
{
|
||
type: "path",
|
||
d: "M10 -2.5l2 2 3.5-4",
|
||
stroke: "#FFFFFF",
|
||
strokeWidth: 1.5,
|
||
fill: "none",
|
||
round: true,
|
||
},
|
||
],
|
||
{ transform: "translate(-40 2) rotate(-6)" },
|
||
),
|
||
];
|
||
case "cooking":
|
||
return [
|
||
{ type: "rect", x: -2.2, y: -14, width: 4.4, height: 40, rx: 2.2, fill: "#C98E52" },
|
||
{ type: "ellipse", cx: 0, cy: 28, rx: 6, ry: 4, fill: "#C98E52" },
|
||
];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function headlamp(top: number): OoShape[] {
|
||
return [
|
||
{
|
||
type: "path",
|
||
d: `M93 ${top + 19}L60 ${top + 70}H140L107 ${top + 19}Z`,
|
||
fill: "#FFE27A",
|
||
opacity: 0.16,
|
||
},
|
||
{
|
||
type: "path",
|
||
d: `M48 ${top + 22}Q100 ${top + 6} 152 ${top + 22}`,
|
||
fill: "none",
|
||
stroke: "#3A3346",
|
||
strokeWidth: 5,
|
||
round: true,
|
||
},
|
||
{
|
||
type: "circle",
|
||
cx: 100,
|
||
cy: top + 13,
|
||
r: 7,
|
||
fill: "#FFE27A",
|
||
stroke: "#3A3346",
|
||
strokeWidth: 2.5,
|
||
},
|
||
];
|
||
}
|
||
|
||
function fx(moment: OoMoment): OoShape[] {
|
||
const item = (kind: OoFxKind, index: number, children: OoShape[]) =>
|
||
g(children, { part: "fx", fx: { kind, index } });
|
||
const out: OoShape[] = [];
|
||
switch (moment) {
|
||
case "thinking": {
|
||
const dots = [
|
||
[148, 64, 4],
|
||
[160, 48, 6],
|
||
[176, 30, 9],
|
||
] as const;
|
||
for (const [i, [cx, cy, r]] of dots.entries()) {
|
||
out.push(
|
||
item("dot", i, [
|
||
{
|
||
type: "circle",
|
||
cx,
|
||
cy,
|
||
r,
|
||
fill: "#FFFFFF",
|
||
stroke: INK,
|
||
strokeOpacity: 0.25,
|
||
strokeWidth: 1.5,
|
||
},
|
||
]),
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
case "asleep":
|
||
for (let i = 0; i < 3; i++) {
|
||
const s = 6 + i * 2;
|
||
out.push(
|
||
item("zzz", i, [
|
||
{
|
||
type: "path",
|
||
d: `M0 0H${s}L0 ${s}H${s}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeOpacity: 0.55,
|
||
strokeWidth: 2.4,
|
||
round: true,
|
||
},
|
||
]),
|
||
);
|
||
}
|
||
break;
|
||
case "success":
|
||
for (let i = 0; i < 6; i++) {
|
||
out.push(
|
||
item("spark", i, [
|
||
{
|
||
type: "path",
|
||
d: ooStarPath(0, 0, 7),
|
||
fill: ["#FFCB45", "#FF8FB5", "#7CC8FF"][i % 3],
|
||
},
|
||
]),
|
||
);
|
||
}
|
||
break;
|
||
case "greeting":
|
||
for (let i = 0; i < 2; i++)
|
||
out.push(
|
||
item("spark", 10 + i, [{ type: "path", d: ooStarPath(0, 0, 6), fill: "#FFCB45" }]),
|
||
);
|
||
break;
|
||
case "offering":
|
||
out.push(item("spark", 20, [{ type: "path", d: ooStarPath(0, 0, 5), fill: "#FFCB45" }]));
|
||
break;
|
||
case "concerned":
|
||
out.push(
|
||
item("sweat", 0, [
|
||
{
|
||
type: "path",
|
||
d: "M0 -9Q6 0 6 4A6 6 0 0 1 -6 4Q-6 0 0 -9Z",
|
||
fill: "#9AD3FF",
|
||
stroke: "#5FA8E0",
|
||
strokeWidth: 1.2,
|
||
},
|
||
]),
|
||
);
|
||
break;
|
||
case "listening":
|
||
for (let i = 0; i < 3; i++) {
|
||
const h = 10 + i * 7;
|
||
out.push(
|
||
item("wave", i, [
|
||
{
|
||
type: "path",
|
||
d: `M0 -${h}Q-${7 + i * 4} 0 0 ${h}`,
|
||
fill: "none",
|
||
stroke: INK,
|
||
strokeOpacity: 0.4,
|
||
strokeWidth: 2.4,
|
||
round: true,
|
||
},
|
||
]),
|
||
);
|
||
}
|
||
break;
|
||
case "waiting":
|
||
out.push(
|
||
item("bubble", 0, [
|
||
{
|
||
type: "path",
|
||
d: "M0 0H40Q48 0 48 8V20Q48 28 40 28H14L6 35L8 28Q0 28 0 20V8Q0 0 8 0Z",
|
||
fill: "#FFFFFF",
|
||
stroke: INK,
|
||
strokeOpacity: 0.2,
|
||
strokeWidth: 1.4,
|
||
},
|
||
]),
|
||
);
|
||
for (let i = 0; i < 3; i++)
|
||
out.push(
|
||
item("bubble-dot", i, [
|
||
{ type: "circle", cx: 0, cy: 0, r: 3, fill: INK, fillOpacity: 0.6 },
|
||
]),
|
||
);
|
||
break;
|
||
case "cooking":
|
||
for (let i = 0; i < 3; i++) {
|
||
out.push(
|
||
item("steam", i, [
|
||
{
|
||
type: "path",
|
||
d: "M0 0Q-5 -7 0 -14Q5 -21 0 -28",
|
||
fill: "none",
|
||
stroke: "#B9B2C2",
|
||
strokeWidth: 3,
|
||
round: true,
|
||
},
|
||
]),
|
||
);
|
||
}
|
||
break;
|
||
case "autopilot":
|
||
out.push(
|
||
item("moon", 0, [
|
||
{ type: "path", d: "M0 -13A13 13 0 1 0 11 7A10 10 0 1 1 0 -13Z", fill: "#FFE27A" },
|
||
]),
|
||
);
|
||
for (let i = 0; i < 3; i++)
|
||
out.push(item("star", i, [{ type: "path", d: ooStarPath(0, 0, 4.5), fill: "#FFE27A" }]));
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/* ---------- assembly ---------- */
|
||
|
||
export interface OoRigInput {
|
||
avatar: OoAvatar;
|
||
/** Resolved body colour (`#RRGGBB`). */
|
||
color: string;
|
||
/** Classic silhouette index used when the avatar names none. */
|
||
fallbackShape: number;
|
||
moment: OoMoment;
|
||
lod: OoLod;
|
||
}
|
||
|
||
export function buildOoRig({ avatar, color, fallbackShape, moment, lod }: OoRigInput): OoRig {
|
||
const species = SPECIES[avatar.species] ?? SPECIES.classic;
|
||
const c = ooBodyColors(color);
|
||
const body = species.body(avatar.shape ?? fallbackShape);
|
||
const top = body.top ?? species.top;
|
||
const mid = lod !== "xs";
|
||
const full = lod === "full";
|
||
const head = avatar.head !== "none" ? avatar.head : moment === "cooking" ? "chef" : "none";
|
||
const feetColor = species.feetColor ?? c.dark;
|
||
const bodyPaint = (fill: string, extra: OoPaint = {}): OoShape =>
|
||
body.transform
|
||
? g([{ type: "path", d: body.d, fill, ...extra }], { transform: body.transform })
|
||
: { type: "path", d: body.d, fill, ...extra };
|
||
|
||
const rig: OoShape[] = [...(species.back?.(c) ?? [])];
|
||
if (species.feet !== false) {
|
||
rig.push({ type: "ellipse", cx: 80, cy: 167, rx: 14, ry: 7.5, fill: feetColor });
|
||
rig.push({ type: "ellipse", cx: 122, cy: 167, rx: 14, ry: 7.5, fill: feetColor });
|
||
}
|
||
rig.push(
|
||
bodyPaint("$body", {
|
||
stroke: c.edge,
|
||
// Classic paths are scaled down; keep the outline as thin as the others.
|
||
strokeWidth: body.transform ? 1.6 / CLASSIC_SCALE : 1.6,
|
||
...(species.bodyOpacity ? { opacity: species.bodyOpacity } : {}),
|
||
}),
|
||
);
|
||
rig.push(bodyPaint("$shine"));
|
||
rig.push(...(species.front?.(c) ?? []));
|
||
rig.push(...cheeks(avatar));
|
||
rig.push(...eye("big", avatar, lod), ...eye("small", avatar, lod));
|
||
rig.push(...brows(avatar));
|
||
const mouthColor = species.mouthColor ?? INK;
|
||
rig.push(g([{ type: "path", d: "", fill: "#FF7FA6" }], { part: "tongue" }));
|
||
rig.push(
|
||
g(
|
||
[
|
||
{
|
||
type: "path",
|
||
d: "M94 138Q103 146 112 138",
|
||
fill: "none",
|
||
stroke: mouthColor,
|
||
strokeWidth: 3.2,
|
||
round: true,
|
||
},
|
||
],
|
||
{
|
||
part: "mouth",
|
||
},
|
||
),
|
||
);
|
||
rig.push(...(species.over?.(c) ?? []));
|
||
rig.push(...eyewear(avatar.eyewear));
|
||
if (mid) {
|
||
rig.push(...neckwear(avatar.neck, avatar.species === "frog" ? 160 : 158));
|
||
rig.push(...headwear(head, top));
|
||
}
|
||
if (full && moment === "autopilot") rig.push(...headlamp(top));
|
||
if (mid) {
|
||
rig.push(...frontProp(moment));
|
||
rig.push(
|
||
g(arm(avatar.arms, c), { part: "arm-left", transform: "translate(46 124) rotate(12)" }),
|
||
);
|
||
rig.push(
|
||
g(
|
||
[
|
||
...arm(avatar.arms, c),
|
||
g([g(handProp(moment), { part: "hand-right" })], { transform: "translate(0 30)" }),
|
||
],
|
||
{
|
||
part: "arm-right",
|
||
transform: "translate(154 124) rotate(-12)",
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
const shapes: OoShape[] = [
|
||
g([{ type: "ellipse", cx: 100, cy: 178, rx: 46, ry: 6, fill: INK, opacity: 0.12 }], {
|
||
part: "shadow",
|
||
}),
|
||
g(rig, { part: "rig" }),
|
||
];
|
||
if (full) shapes.push(...fx(moment));
|
||
return {
|
||
shapes,
|
||
colors: c,
|
||
mouthColor,
|
||
mouthStyle: species.mouth ?? "smile",
|
||
floatScale: species.float ?? 1,
|
||
};
|
||
}
|