/** * 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: "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 = { tablet: 40, wedge: 6, cloud: 22, teardrop: 0 }; const SPECIES: Record = { 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 = { rose: "#FF8FB5", lilac: "#B48CFF", gold: "#F5C04A", mint: "#5FD9B0", }; const IRIS: Record = { 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 = { 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, }; }