feat(avatar): draw every bot as a living Oo on web (#136)
Bots now render as an Oo rig built once in core: nine species, styled Oo eyes, eyewear, headwear, neckwear, arms and moment props, with a level of detail per size. A shared animation frame loop moves only visible avatars and holds still under reduced motion. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dbf37f86a8
commit
9f4991abe0
@ -21,4 +21,9 @@ test("bot avatar ring stays still when reduced motion is enabled", async ({ page
|
|||||||
|
|
||||||
expect(first.animationName).toBe("none");
|
expect(first.animationName).toBe("none");
|
||||||
expect(second).toEqual(first);
|
expect(second).toEqual(first);
|
||||||
|
|
||||||
|
const rig = avatar.locator('[data-part="rig"]');
|
||||||
|
const still = await rig.getAttribute("transform");
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
expect(await rig.getAttribute("transform")).toBe(still);
|
||||||
});
|
});
|
||||||
|
|||||||
12
apps/web/e2e/fixtures/oo-gallery.html
Normal file
12
apps/web/e2e/fixtures/oo-gallery.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Oo gallery</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/e2e/fixtures/oo-gallery.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
apps/web/e2e/fixtures/oo-gallery.tsx
Normal file
62
apps/web/e2e/fixtures/oo-gallery.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { OoMoment } from "@rakazo/core";
|
||||||
|
import { BotAvatar } from "@rakazo/ui-web";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "../../src/styles.css";
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
document.documentElement.dataset.theme = params.get("theme") === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
|
const crew: { name: string; look: string; moment: OoMoment }[] = [
|
||||||
|
{ name: "Classic", look: "#8B5CF6::shape_3", moment: "idle" },
|
||||||
|
{
|
||||||
|
name: "Miso",
|
||||||
|
look: "oo1:c=FF8A33;sp=cat;la=soft;li=wing;ir=green;ew=round;pl=0.80",
|
||||||
|
moment: "working",
|
||||||
|
},
|
||||||
|
{ name: "Bruno", look: "oo1:c=B38559;sp=dog;ar=paw;ck=peach;nk=scarf", moment: "cooking" },
|
||||||
|
{
|
||||||
|
name: "Luna",
|
||||||
|
look: "oo1:c=FFFFFF;sp=bunny;la=full;es=rose;ir=violet;sk=heart;ck=pink;ew=heart",
|
||||||
|
moment: "success",
|
||||||
|
},
|
||||||
|
{ name: "Teddy", look: "oo1:c=C98A5A;sp=bear;ar=paw;hd=beanie", moment: "reading" },
|
||||||
|
{
|
||||||
|
name: "Pixel",
|
||||||
|
look: "oo1:c=A7B1C2;sp=robot;ar=robot;sk=star;hd=headphones",
|
||||||
|
moment: "listening",
|
||||||
|
},
|
||||||
|
{ name: "Boo", look: "oo1:c=CDB8FF;sp=ghost;en=0.80", moment: "autopilot" },
|
||||||
|
{ name: "Kermi", look: "oo1:c=7BD389;sp=frog;ew=nerd;nk=bowtie", moment: "thinking" },
|
||||||
|
{ name: "Pingu", look: "oo1:c=3A4A6B;sp=penguin;hd=crown;ew=monocle", moment: "offering" },
|
||||||
|
{ name: "Waiting", look: "oo1:c=10B981;br=fine;hd=flower", moment: "waiting" },
|
||||||
|
{ name: "Worried", look: "oo1:c=F59E0B;sp=bunny;ew=reading", moment: "concerned" },
|
||||||
|
{ name: "Sleepy", look: "oo1:c=3D8BFF;sp=cat;nk=bell;hd=party", moment: "asleep" },
|
||||||
|
];
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div data-testid="oo-gallery" className="grid grid-cols-4 gap-4">
|
||||||
|
{crew.map(({ name, look, moment }) => (
|
||||||
|
<figure
|
||||||
|
key={name}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-xl border border-border bg-card p-2"
|
||||||
|
>
|
||||||
|
<BotAvatar color={look} identity={name} size={120} moment={moment} still />
|
||||||
|
<figcaption className="text-ui-body-sm text-muted-foreground">{moment}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div data-testid="oo-sizes" className="mt-6 flex items-end gap-4">
|
||||||
|
{[20, 28, 44, 72].map((size) => (
|
||||||
|
<BotAvatar
|
||||||
|
key={size}
|
||||||
|
color={crew[1]!.look}
|
||||||
|
identity="sizes"
|
||||||
|
size={size}
|
||||||
|
status="running"
|
||||||
|
still
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>,
|
||||||
|
);
|
||||||
29
apps/web/e2e/oo-avatar.spec.ts
Normal file
29
apps/web/e2e/oo-avatar.spec.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot } from "./helpers";
|
||||||
|
|
||||||
|
for (const theme of ["light", "dark"] as const) {
|
||||||
|
test(`Oo looks render every species with the Oo eyes (${theme})`, async ({ page }, testInfo) => {
|
||||||
|
await page.goto(`/e2e/fixtures/oo-gallery.html?theme=${theme}`);
|
||||||
|
const gallery = page.getByTestId("oo-gallery");
|
||||||
|
await expect(gallery.locator("svg[data-oo-species]")).toHaveCount(12);
|
||||||
|
for (const species of ["cat", "dog", "bunny", "bear", "robot", "ghost", "frog", "penguin"]) {
|
||||||
|
await expect(gallery.locator(`svg[data-oo-species="${species}"]`).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
await expect(gallery.locator('[data-part="eye-big"]')).toHaveCount(12);
|
||||||
|
const sizes = page.getByTestId("oo-sizes");
|
||||||
|
await expect(
|
||||||
|
sizes.locator("svg[data-oo-species]").first().locator('[data-part="arm-left"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
sizes.locator("svg[data-oo-species]").last().locator('[data-part="arm-left"]'),
|
||||||
|
).toHaveCount(1);
|
||||||
|
await captureScreenshot(page, testInfo, `oo-gallery-${theme}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Oo keeps blinking and moving while visible", async ({ page }) => {
|
||||||
|
await page.goto("/e2e/fixtures/avatar-motion.html");
|
||||||
|
const rig = page.locator('.rakazo-bot-avatar svg[data-oo-species] [data-part="rig"]');
|
||||||
|
const first = await rig.getAttribute("transform");
|
||||||
|
await expect.poll(() => rig.getAttribute("transform")).not.toBe(first);
|
||||||
|
});
|
||||||
@ -17,7 +17,6 @@ vi.mock("@lingui/react/macro", () => {
|
|||||||
vi.mock("@rakazo/ui-web", () => ({
|
vi.mock("@rakazo/ui-web", () => ({
|
||||||
BotAvatar: () => <span data-avatar="" />,
|
BotAvatar: () => <span data-avatar="" />,
|
||||||
parseBotAvatar: (color: string) => ({ color }),
|
parseBotAvatar: (color: string) => ({ color }),
|
||||||
resolvePersonaColorDef: (_id: string, color: string) => ({ light: color }),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("./AskCard", () => ({
|
vi.mock("./AskCard", () => ({
|
||||||
AskCard: ({
|
AskCard: ({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import type { Bot, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
import type { Bot, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
||||||
import { BotAvatar, parseBotAvatar, resolvePersonaColorDef } from "@rakazo/ui-web";
|
import { resolveOoColor } from "@rakazo/core";
|
||||||
|
import { BotAvatar, parseBotAvatar } from "@rakazo/ui-web";
|
||||||
import { Monitor } from "lucide-react";
|
import { Monitor } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { inboxItemKey, inboxRefreshSignal } from "../lib/main-inbox";
|
import { inboxItemKey, inboxRefreshSignal } from "../lib/main-inbox";
|
||||||
@ -167,7 +168,7 @@ export function MainInbox({
|
|||||||
|
|
||||||
/** The bot, and the space it works in when that is not the current one. */
|
/** The bot, and the space it works in when that is not the current one. */
|
||||||
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
|
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
|
||||||
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
|
const color = resolveOoColor(bot.id, parseBotAvatar(bot.color).color).light;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { OO_DEFAULT_COLOR, OO_PALETTE_HEXES } from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
BotAvatar,
|
BotAvatar,
|
||||||
DEFAULT_GROK_BOT_COLOR,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
GROK_BOT_COLORS,
|
OoShapePreview,
|
||||||
GrokShapePreview,
|
|
||||||
parseBotAvatar,
|
parseBotAvatar,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { Check, Pencil, Upload, X } from "lucide-react";
|
import { Check, Pencil, Upload, X } from "lucide-react";
|
||||||
@ -39,7 +38,7 @@ export function AvatarStudioPopover({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const parsed = parseBotAvatar(value, identity);
|
const parsed = parseBotAvatar(value, identity);
|
||||||
const currentColor = parsed.color || DEFAULT_GROK_BOT_COLOR;
|
const currentColor = parsed.color || OO_DEFAULT_COLOR;
|
||||||
const currentShape = parsed.shapeIndex ?? 0;
|
const currentShape = parsed.shapeIndex ?? 0;
|
||||||
|
|
||||||
function selectShape(shapeIndex: number) {
|
function selectShape(shapeIndex: number) {
|
||||||
@ -51,7 +50,7 @@ export function AvatarStudioPopover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetAvatar() {
|
function resetAvatar() {
|
||||||
onChange(`${DEFAULT_GROK_BOT_COLOR}::shape_0`);
|
onChange(`${OO_DEFAULT_COLOR}::shape_0`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processImageFile(file: File) {
|
function processImageFile(file: File) {
|
||||||
@ -195,7 +194,7 @@ export function AvatarStudioPopover({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 place-items-center gap-2">
|
<div className="grid grid-cols-4 place-items-center gap-2">
|
||||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((shapeIndex) => (
|
{[0, 1, 2, 3, 4, 5, 6, 7].map((shapeIndex) => (
|
||||||
<GrokShapePreview
|
<OoShapePreview
|
||||||
key={shapeIndex}
|
key={shapeIndex}
|
||||||
shapeIndex={shapeIndex}
|
shapeIndex={shapeIndex}
|
||||||
color={currentColor}
|
color={currentColor}
|
||||||
@ -211,7 +210,7 @@ export function AvatarStudioPopover({
|
|||||||
<Trans>Color</Trans>
|
<Trans>Color</Trans>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-6 place-items-center gap-2">
|
<div className="grid grid-cols-6 place-items-center gap-2">
|
||||||
{GROK_BOT_COLORS.map((color) => {
|
{OO_PALETTE_HEXES.map((color) => {
|
||||||
const selected =
|
const selected =
|
||||||
currentColor.toLowerCase() === color.toLowerCase() && !parsed.isImage;
|
currentColor.toLowerCase() === color.toLowerCase() && !parsed.isImage;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
isRoutineEventMessage,
|
isRoutineEventMessage,
|
||||||
isToolActivityBlock,
|
isToolActivityBlock,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
|
resolveOoColor,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import type { GroupAvatarMember } from "@rakazo/ui-web";
|
import type { GroupAvatarMember } from "@rakazo/ui-web";
|
||||||
import {
|
import {
|
||||||
@ -22,7 +23,6 @@ import {
|
|||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
resolvePersonaColorDef,
|
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { ArrowDown, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2 } from "lucide-react";
|
import { ArrowDown, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2 } from "lucide-react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
@ -779,7 +779,7 @@ const MessageView = memo(function MessageView({
|
|||||||
const parentJumpId = replyPreview?.id ?? replyToMessageId;
|
const parentJumpId = replyPreview?.id ?? replyToMessageId;
|
||||||
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
|
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
|
||||||
const speakerColorDef = useMemo(
|
const speakerColorDef = useMemo(
|
||||||
() => resolvePersonaColorDef(message.botId ?? "bot", speakerBot?.color),
|
() => resolveOoColor(message.botId ?? "bot", speakerBot?.color),
|
||||||
[message.botId, speakerBot?.color],
|
[message.botId, speakerBot?.color],
|
||||||
);
|
);
|
||||||
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
|
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
|
||||||
|
|||||||
@ -33,6 +33,9 @@ export * from "./model-oauth.js";
|
|||||||
export * from "./model-probe.js";
|
export * from "./model-probe.js";
|
||||||
export * from "./model-providers.js";
|
export * from "./model-providers.js";
|
||||||
export * from "./oo-avatar.js";
|
export * from "./oo-avatar.js";
|
||||||
|
export * from "./oo-motion.js";
|
||||||
|
export * from "./oo-palette.js";
|
||||||
|
export * from "./oo-rig.js";
|
||||||
export * from "./quiet-hours.js";
|
export * from "./quiet-hours.js";
|
||||||
export * from "./response-bytes.js";
|
export * from "./response-bytes.js";
|
||||||
export * from "./routine-events.js";
|
export * from "./routine-events.js";
|
||||||
|
|||||||
559
packages/core/src/oo-motion.ts
Normal file
559
packages/core/src/oo-motion.ts
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
/**
|
||||||
|
* Oo motion: what every animated part of an Oo rig looks like at time `t`.
|
||||||
|
*
|
||||||
|
* `ooFrame` is pure apart from the `OoLife` it advances (blinks, glances, playful
|
||||||
|
* extras), and that life draws from an injected random source, so a seeded source
|
||||||
|
* gives the same frames on every platform and in tests.
|
||||||
|
*/
|
||||||
|
import type { OoAvatar } from "./oo-avatar.js";
|
||||||
|
import { OO_RIG_EYES } from "./oo-avatar.js";
|
||||||
|
import type { OoFxKind, OoMoment, OoPart, OoRig, OoShape } from "./oo-rig.js";
|
||||||
|
|
||||||
|
export interface OoPartState {
|
||||||
|
transform?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
opacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OoFrame {
|
||||||
|
parts: Partial<Record<Exclude<OoPart, "fx" | "mouth" | "tongue">, OoPartState>>;
|
||||||
|
mouth: { d: string; filled: boolean };
|
||||||
|
tongue: string;
|
||||||
|
fx: (kind: OoFxKind, index: number) => OoPartState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OoLife {
|
||||||
|
random: () => number;
|
||||||
|
nextBlink: number;
|
||||||
|
blinkAt: number;
|
||||||
|
gaze: { x: number; y: number };
|
||||||
|
gazeTarget: { x: number; y: number };
|
||||||
|
nextLook: number;
|
||||||
|
extraAt: number;
|
||||||
|
extraKind: "ear" | "hop" | "look" | null;
|
||||||
|
nextExtra: number;
|
||||||
|
boopAt: number;
|
||||||
|
/** Pointer direction in −1…1 when someone hovers the avatar. */
|
||||||
|
pointer: { x: number; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOoLife(random: () => number = Math.random): OoLife {
|
||||||
|
return {
|
||||||
|
random,
|
||||||
|
nextBlink: 1 + random() * 2,
|
||||||
|
blinkAt: -10,
|
||||||
|
gaze: { x: 0, y: 0 },
|
||||||
|
gazeTarget: { x: 0, y: 0 },
|
||||||
|
nextLook: 2,
|
||||||
|
extraAt: -10,
|
||||||
|
extraKind: null,
|
||||||
|
nextExtra: 4,
|
||||||
|
boopAt: -10,
|
||||||
|
pointer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic random source (mulberry32) for tests and still renders. */
|
||||||
|
export function ooSeededRandom(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time a still Oo is drawn at: mid-breath, eyes open. */
|
||||||
|
export const OO_STILL_TIME = 0.6;
|
||||||
|
|
||||||
|
/** Moments that keep moving inside dense lists; everything else holds still there. */
|
||||||
|
export const OO_LIST_MOMENTS: readonly OoMoment[] = ["working", "waiting", "autopilot"];
|
||||||
|
|
||||||
|
/** Bot run status → moment. */
|
||||||
|
export function ooMomentForStatus(status?: string, streaming = false): OoMoment {
|
||||||
|
if (streaming) return "speaking";
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
case "queued":
|
||||||
|
case "leased":
|
||||||
|
return "working";
|
||||||
|
case "waiting_input":
|
||||||
|
return "waiting";
|
||||||
|
case "error":
|
||||||
|
case "failed":
|
||||||
|
return "concerned";
|
||||||
|
case "booting":
|
||||||
|
return "reading";
|
||||||
|
default:
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mouth =
|
||||||
|
| "smile"
|
||||||
|
| "cat"
|
||||||
|
| "wide"
|
||||||
|
| "grin"
|
||||||
|
| "o"
|
||||||
|
| "yawn"
|
||||||
|
| "flat"
|
||||||
|
| "hmm"
|
||||||
|
| "worried"
|
||||||
|
| "tongue"
|
||||||
|
| "sleep"
|
||||||
|
| "talk";
|
||||||
|
|
||||||
|
interface Brow {
|
||||||
|
a: number;
|
||||||
|
dy: number;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pose {
|
||||||
|
bob: number;
|
||||||
|
tilt: number;
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
armL: number;
|
||||||
|
armR: number;
|
||||||
|
gx: number;
|
||||||
|
gy: number;
|
||||||
|
open: number;
|
||||||
|
eyes: "open" | "closed" | "happy";
|
||||||
|
mouth: Mouth;
|
||||||
|
browB: Brow;
|
||||||
|
browS: Brow;
|
||||||
|
tail: number;
|
||||||
|
earL: number;
|
||||||
|
earR: number;
|
||||||
|
freeGaze: boolean;
|
||||||
|
happyTail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
function pose(avatar: OoAvatar, moment: OoMoment, t: number, life: OoLife, still: boolean): Pose {
|
||||||
|
const E = avatar.energy;
|
||||||
|
const P = avatar.playful;
|
||||||
|
const osc = (period: number, phase = 0) => Math.sin((TAU * t * E) / period + phase);
|
||||||
|
const o: Pose = {
|
||||||
|
bob: osc(3.4) * 1.5,
|
||||||
|
tilt: 0,
|
||||||
|
sx: 1,
|
||||||
|
sy: 1 + osc(3.4) * 0.012,
|
||||||
|
armL: 12 + osc(3.4) * 3,
|
||||||
|
armR: 12 + osc(3.4, 0.5) * 3,
|
||||||
|
gx: 0,
|
||||||
|
gy: 0,
|
||||||
|
open: 1,
|
||||||
|
eyes: "open",
|
||||||
|
mouth: "smile",
|
||||||
|
browB: { a: 0, dy: 0, show: false },
|
||||||
|
browS: { a: 0, dy: 0, show: false },
|
||||||
|
tail: osc(2.6) * 6,
|
||||||
|
earL: 0,
|
||||||
|
earR: 0,
|
||||||
|
freeGaze: true,
|
||||||
|
happyTail: false,
|
||||||
|
};
|
||||||
|
switch (moment) {
|
||||||
|
case "greeting":
|
||||||
|
o.armR = 150 + osc(0.55) * 24;
|
||||||
|
o.tilt = osc(1.1) * 3;
|
||||||
|
o.mouth = "grin";
|
||||||
|
o.happyTail = true;
|
||||||
|
break;
|
||||||
|
case "listening":
|
||||||
|
o.tilt = 7 + osc(4) * 1.5;
|
||||||
|
o.mouth = "o";
|
||||||
|
o.open = 1.06;
|
||||||
|
o.gy = -0.15;
|
||||||
|
o.freeGaze = false;
|
||||||
|
o.earL = -8;
|
||||||
|
o.armL = 8;
|
||||||
|
o.armR = 8;
|
||||||
|
break;
|
||||||
|
case "speaking":
|
||||||
|
o.mouth = osc(0.32) > 0 ? "talk" : "smile";
|
||||||
|
o.tilt = osc(2.2) * 2;
|
||||||
|
o.armR = 30 + osc(1.4) * 10;
|
||||||
|
break;
|
||||||
|
case "thinking":
|
||||||
|
o.gx = 0.85;
|
||||||
|
o.gy = -0.85;
|
||||||
|
o.tilt = -4;
|
||||||
|
o.armR = -62 + osc(1.3) * 4;
|
||||||
|
o.mouth = "hmm";
|
||||||
|
o.browS = { a: -6, dy: -4, show: true };
|
||||||
|
o.browB = { a: 4, dy: 0, show: true };
|
||||||
|
o.freeGaze = false;
|
||||||
|
break;
|
||||||
|
case "working":
|
||||||
|
case "autopilot": {
|
||||||
|
const slow = moment === "autopilot" ? 1.7 : 1;
|
||||||
|
o.bob = Math.abs(osc(0.5 * slow)) * -1.2;
|
||||||
|
o.armL = -30 + osc(0.24 * slow) * 7;
|
||||||
|
o.armR = -30 + osc(0.24 * slow, Math.PI) * 7;
|
||||||
|
o.gx = osc(2.2 * slow) * 0.25;
|
||||||
|
o.gy = 0.75;
|
||||||
|
o.open = moment === "autopilot" ? 0.78 : 0.92;
|
||||||
|
o.mouth = P > 0.55 ? "tongue" : "flat";
|
||||||
|
o.freeGaze = false;
|
||||||
|
if (moment === "autopilot") {
|
||||||
|
const cycle = (t * E) % 9;
|
||||||
|
if (cycle > 7.6 && cycle < 8.8) {
|
||||||
|
o.mouth = "yawn";
|
||||||
|
o.open = 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "reading":
|
||||||
|
o.armL = -40;
|
||||||
|
o.armR = -40;
|
||||||
|
o.gx = -0.6 + (((t * E) / 1.8) % 1) * 1.2;
|
||||||
|
o.gy = 0.55 + Math.floor(((t * E) / 1.8) % 3) * 0.15;
|
||||||
|
o.mouth = "o";
|
||||||
|
o.freeGaze = false;
|
||||||
|
o.bob = osc(3);
|
||||||
|
break;
|
||||||
|
case "waiting": {
|
||||||
|
const phase = (t * E) % 2.8;
|
||||||
|
const hop = phase < 0.45 ? Math.sin((phase / 0.45) * Math.PI) : 0;
|
||||||
|
o.bob = -hop * 7;
|
||||||
|
o.sy =
|
||||||
|
1 -
|
||||||
|
hop * 0.04 +
|
||||||
|
(phase > 0.45 && phase < 0.6 ? -0.05 * Math.sin(((phase - 0.45) / 0.15) * Math.PI) : 0);
|
||||||
|
o.armL = 12 + hop * 40;
|
||||||
|
o.open = 1.06;
|
||||||
|
o.freeGaze = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "offering": {
|
||||||
|
const glance = Math.sin((TAU * t * E) / 3.2) > 0;
|
||||||
|
o.armR = 40 + osc(1.6) * 3;
|
||||||
|
o.gx = glance ? 0.8 : 0;
|
||||||
|
o.gy = glance ? 0.45 : 0;
|
||||||
|
o.freeGaze = false;
|
||||||
|
o.tilt = 3;
|
||||||
|
o.sx = 1.02;
|
||||||
|
o.happyTail = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "cooking":
|
||||||
|
o.armR = -55 + Math.sin((TAU * t * E) / 1.1) * 9;
|
||||||
|
o.armL = 10;
|
||||||
|
o.gx = 0.15 + Math.cos((TAU * t * E) / 1.1) * 0.25;
|
||||||
|
o.gy = 0.7;
|
||||||
|
o.freeGaze = false;
|
||||||
|
o.mouth = P > 0.55 ? "tongue" : "smile";
|
||||||
|
o.happyTail = true;
|
||||||
|
break;
|
||||||
|
case "success": {
|
||||||
|
const phase = (t * E * 1.1) % 1;
|
||||||
|
const jump = Math.sin(phase * Math.PI);
|
||||||
|
const land = phase > 0.88 || phase < 0.06;
|
||||||
|
o.bob = -jump * 12 * Math.min(1.4, E);
|
||||||
|
o.sy = land ? 0.92 : 1 + jump * 0.04;
|
||||||
|
o.sx = land ? 1.06 : 1;
|
||||||
|
o.armL = 150 + osc(0.5) * 12;
|
||||||
|
o.armR = 150 + osc(0.5, 1) * 12;
|
||||||
|
o.eyes = "happy";
|
||||||
|
o.mouth = "grin";
|
||||||
|
o.happyTail = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "concerned":
|
||||||
|
o.tilt = osc(0.18) * 0.8;
|
||||||
|
o.armL = -22;
|
||||||
|
o.armR = -22;
|
||||||
|
o.gy = 0.45;
|
||||||
|
o.mouth = "worried";
|
||||||
|
o.browB = { a: -14, dy: -1, show: true };
|
||||||
|
o.browS = { a: 14, dy: -1, show: true };
|
||||||
|
o.freeGaze = false;
|
||||||
|
o.tail = 0;
|
||||||
|
break;
|
||||||
|
case "asleep":
|
||||||
|
o.eyes = "closed";
|
||||||
|
o.bob = osc(5) * 2;
|
||||||
|
o.sy = 1 + osc(5) * 0.025;
|
||||||
|
o.tilt = 6;
|
||||||
|
o.armL = 4;
|
||||||
|
o.armR = 4;
|
||||||
|
o.mouth = "sleep";
|
||||||
|
o.tail = 0;
|
||||||
|
o.freeGaze = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (o.happyTail) o.tail = Math.sin((TAU * t * E) / 0.36) * 16;
|
||||||
|
|
||||||
|
if (P > 0.25 && !still && moment !== "asleep") {
|
||||||
|
if (t > life.nextExtra) {
|
||||||
|
life.extraAt = t;
|
||||||
|
life.extraKind = (["ear", "hop", "look"] as const)[Math.floor(life.random() * 3)] ?? "ear";
|
||||||
|
life.nextExtra = t + (7 - P * 4) / E + life.random() * 3;
|
||||||
|
}
|
||||||
|
const since = t - life.extraAt;
|
||||||
|
if (since < 0.6 && life.extraKind === "ear") o.earR += -18 * Math.sin((since / 0.6) * Math.PI);
|
||||||
|
if (since < 0.5 && life.extraKind === "hop" && (moment === "idle" || moment === "greeting")) {
|
||||||
|
o.bob -= 5 * Math.sin((since / 0.5) * Math.PI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (avatar.brows !== "auto") {
|
||||||
|
o.browB.show = true;
|
||||||
|
o.browS.show = true;
|
||||||
|
}
|
||||||
|
const boop = t - life.boopAt;
|
||||||
|
if (boop >= 0 && boop < 1) {
|
||||||
|
const k = Math.sin(Math.min(1, boop / 0.25) * Math.PI);
|
||||||
|
if (boop < 0.25) {
|
||||||
|
o.sx *= 1 + k * 0.08;
|
||||||
|
o.sy *= 1 - k * 0.1;
|
||||||
|
}
|
||||||
|
if (boop > 0.2 && boop < 0.7) o.bob -= Math.sin(((boop - 0.2) / 0.5) * Math.PI) * 6;
|
||||||
|
o.eyes = "happy";
|
||||||
|
o.mouth = "grin";
|
||||||
|
o.tail = Math.sin((TAU * t) / 0.3) * 18;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOUTHS: Record<Mouth, { d: string; filled: boolean; tongue?: string }> = {
|
||||||
|
smile: { d: "M94 138Q103 146 112 138", filled: false },
|
||||||
|
cat: { d: "M93 138Q98 144 103 138Q108 144 113 138", filled: false },
|
||||||
|
wide: { d: "M86 137Q103 150 120 137", filled: false },
|
||||||
|
grin: {
|
||||||
|
d: "M90 136Q103 155 116 136Z",
|
||||||
|
filled: true,
|
||||||
|
tongue: "M97 146Q103 151 109 146Q103 143 97 146Z",
|
||||||
|
},
|
||||||
|
o: { d: "M98 141a5 5.5 0 1 0 10 0a5 5.5 0 1 0 -10 0Z", filled: true },
|
||||||
|
talk: { d: "M96 140a7 5 0 1 0 14 0a7 5 0 1 0 -14 0Z", filled: true },
|
||||||
|
yawn: { d: "M96 141a7 9 0 1 0 14 0a7 9 0 1 0 -14 0Z", filled: true },
|
||||||
|
flat: { d: "M96 141L110 141", filled: false },
|
||||||
|
hmm: { d: "M96 142Q103 139 111 140.5", filled: false },
|
||||||
|
worried: { d: "M94 144Q103 136 112 144", filled: false },
|
||||||
|
tongue: {
|
||||||
|
d: "M95 140Q102 142.5 110 140",
|
||||||
|
filled: false,
|
||||||
|
tongue: "M104 141.5Q105 148 109 147Q111 145 109.5 140.5Z",
|
||||||
|
},
|
||||||
|
sleep: { d: "M99 141Q103 144 107 141", filled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUPIL_TRAVEL = { big: { x: 7.4, y: 6.6 }, small: { x: 4.4, y: 4 } } as const;
|
||||||
|
const BROW_LIFT = { big: 8, small: 7 } as const;
|
||||||
|
|
||||||
|
const pivotCache = new WeakMap<OoRig, Partial<Record<OoPart, readonly [number, number]>>>();
|
||||||
|
|
||||||
|
function pivots(rig: OoRig): Partial<Record<OoPart, readonly [number, number]>> {
|
||||||
|
let found = pivotCache.get(rig);
|
||||||
|
if (found) return found;
|
||||||
|
found = {};
|
||||||
|
const walk = (shapes: OoShape[]) => {
|
||||||
|
for (const shape of shapes) {
|
||||||
|
if (shape.type !== "group") continue;
|
||||||
|
if (shape.part && shape.pivot) found![shape.part] = shape.pivot;
|
||||||
|
walk(shape.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(rig.shapes);
|
||||||
|
pivotCache.set(rig, found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f1 = (v: number) => v.toFixed(1);
|
||||||
|
const f2 = (v: number) => v.toFixed(2);
|
||||||
|
|
||||||
|
function fxState(kind: OoFxKind, i: number, t: number): OoPartState {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let s = 1;
|
||||||
|
let op = 1;
|
||||||
|
switch (kind) {
|
||||||
|
case "dot": {
|
||||||
|
const p = (((t * 1.2 - i * 0.25) % 1.5) + 1.5) % 1.5;
|
||||||
|
op = Math.min(1, p * 3) * (p > 1.2 ? 1 - (p - 1.2) / 0.3 : 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "zzz": {
|
||||||
|
const p = (t * 0.45 + i / 3) % 1;
|
||||||
|
x = 146 + p * 22 + Math.sin(p * 6) * 4;
|
||||||
|
y = 78 - p * 58;
|
||||||
|
op = Math.sin(p * Math.PI);
|
||||||
|
s = 0.7 + p * 0.5;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "spark": {
|
||||||
|
const twinkle = Math.sin(t * 5 + i * 1.7);
|
||||||
|
if (i >= 20) {
|
||||||
|
x = 156;
|
||||||
|
y = 96;
|
||||||
|
} else if (i >= 10) {
|
||||||
|
const a = (i - 10) * 1.2 - 2.3;
|
||||||
|
x = 170 + Math.cos(a) * 12;
|
||||||
|
y = 60 + Math.sin(a) * 12;
|
||||||
|
} else {
|
||||||
|
// Success sparks fan out over the upper arc so they never cover the mouth.
|
||||||
|
const a = -Math.PI + (i / 5) * Math.PI;
|
||||||
|
x = 100 + Math.cos(a) * 74 * (0.9 + 0.1 * twinkle);
|
||||||
|
y = 104 + Math.sin(a) * 74 * 0.8 * (0.9 + 0.1 * twinkle);
|
||||||
|
}
|
||||||
|
s = 0.55 + 0.45 * Math.abs(twinkle);
|
||||||
|
op = 0.5 + 0.5 * Math.abs(twinkle);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "sweat": {
|
||||||
|
const p = (t * 0.6) % 1;
|
||||||
|
x = 152;
|
||||||
|
y = 80 + p * 16;
|
||||||
|
op = p < 0.8 ? 1 : 1 - (p - 0.8) / 0.2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "wave": {
|
||||||
|
const p = (((t * 0.9 - i * 0.22) % 1) + 1) % 1;
|
||||||
|
x = 30 - i * 6;
|
||||||
|
y = 108;
|
||||||
|
op = Math.sin(p * Math.PI) * 0.9;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bubble":
|
||||||
|
x = 140;
|
||||||
|
y = 34;
|
||||||
|
s = 1 + Math.sin(t * 2) * 0.03;
|
||||||
|
break;
|
||||||
|
case "bubble-dot":
|
||||||
|
x = 152 + i * 12;
|
||||||
|
y = 48 + (Math.sin(t * 6 - i * 0.9) > 0.3 ? -3 : 0);
|
||||||
|
break;
|
||||||
|
case "steam": {
|
||||||
|
const p = (t * 0.5 + i / 3) % 1;
|
||||||
|
x = 84 + i * 16 + Math.sin(p * 5 + i) * 3;
|
||||||
|
y = 150 - p * 26;
|
||||||
|
op = Math.sin(p * Math.PI) * 0.8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "moon":
|
||||||
|
x = 168;
|
||||||
|
y = 34;
|
||||||
|
break;
|
||||||
|
case "star":
|
||||||
|
x = [30, 48, 186][i] ?? 30;
|
||||||
|
y = [40, 22, 70][i] ?? 40;
|
||||||
|
s = 0.6 + 0.4 * Math.abs(Math.sin(t * 2 + i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
transform: `translate(${f1(x)} ${f1(y)}) scale(${f2(s)})`,
|
||||||
|
opacity: Number(Math.max(0, op).toFixed(2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OoFrameInput {
|
||||||
|
rig: OoRig;
|
||||||
|
avatar: OoAvatar;
|
||||||
|
moment: OoMoment;
|
||||||
|
/** Seconds since this Oo started living. */
|
||||||
|
t: number;
|
||||||
|
life: OoLife;
|
||||||
|
/** Still frames skip blinks, glances and extras (reduced motion, thumbnails). */
|
||||||
|
still?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ooFrame({ rig, avatar, moment, t, life, still = false }: OoFrameInput): OoFrame {
|
||||||
|
const o = pose(avatar, moment, t, life, still);
|
||||||
|
o.bob *= rig.floatScale;
|
||||||
|
if (rig.floatScale !== 1) o.bob += Math.sin((TAU * t * avatar.energy) / 2.8) * 3;
|
||||||
|
|
||||||
|
if (o.freeGaze) {
|
||||||
|
if (life.pointer) life.gazeTarget = life.pointer;
|
||||||
|
else if (!still && t > life.nextLook) {
|
||||||
|
const r = life.random;
|
||||||
|
life.gazeTarget =
|
||||||
|
r() < 0.35 ? { x: 0, y: 0 } : { x: (r() * 2 - 1) * 0.8, y: (r() * 2 - 1) * 0.5 };
|
||||||
|
life.nextLook = t + 1.5 + (r() * 2.5) / avatar.energy;
|
||||||
|
}
|
||||||
|
} else life.gazeTarget = { x: o.gx, y: o.gy };
|
||||||
|
const ease = still ? 1 : 0.18;
|
||||||
|
life.gaze.x += (life.gazeTarget.x - life.gaze.x) * ease;
|
||||||
|
life.gaze.y += (life.gazeTarget.y - life.gaze.y) * ease;
|
||||||
|
|
||||||
|
if (!still && t > life.nextBlink) {
|
||||||
|
life.blinkAt = t;
|
||||||
|
life.nextBlink = t + (2.2 + life.random() * 3) / Math.sqrt(avatar.energy);
|
||||||
|
// Now and then a double blink.
|
||||||
|
if (life.random() < 0.15) life.nextBlink = t + 0.32;
|
||||||
|
}
|
||||||
|
const lid = (delay: number) => {
|
||||||
|
const x = (t - life.blinkAt - delay) / 0.15;
|
||||||
|
return x > 0 && x < 1 ? 1 - Math.sin(x * Math.PI) : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parts: OoFrame["parts"] = {
|
||||||
|
rig: {
|
||||||
|
transform: `translate(0 ${f2(o.bob)}) rotate(${f2(o.tilt)} 100 172) translate(100 172) scale(${o.sx.toFixed(3)} ${o.sy.toFixed(3)}) translate(-100 -172)`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const shadowScale = 1 + o.bob / 40;
|
||||||
|
parts.shadow = {
|
||||||
|
transform: `translate(100 178) scale(${shadowScale.toFixed(3)} 1) translate(-100 -178)`,
|
||||||
|
opacity: Number(Math.min(1, shadowScale).toFixed(3)),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of ["big", "small"] as const) {
|
||||||
|
const { cx, cy, r } = OO_RIG_EYES[key];
|
||||||
|
const open = o.open * lid(key === "big" ? 0 : 0.07);
|
||||||
|
const mode = o.eyes === "open" && open < 0.14 ? "closed" : o.eyes;
|
||||||
|
parts[`eye-${key}`] = {
|
||||||
|
hidden: mode !== "open",
|
||||||
|
transform: `translate(${cx} ${cy}) scale(1 ${Math.min(1.08, open).toFixed(3)}) translate(${-cx} ${-cy})`,
|
||||||
|
};
|
||||||
|
parts[`closed-${key}`] = { hidden: mode !== "closed" };
|
||||||
|
parts[`happy-${key}`] = { hidden: mode !== "happy" };
|
||||||
|
const travel = PUPIL_TRAVEL[key];
|
||||||
|
parts[`pupil-${key}`] = {
|
||||||
|
transform: `translate(${f2(life.gaze.x * travel.x)} ${f2(life.gaze.y * travel.y)})`,
|
||||||
|
};
|
||||||
|
const brow = key === "big" ? o.browB : o.browS;
|
||||||
|
parts[`brow-${key}`] = {
|
||||||
|
hidden: !brow.show,
|
||||||
|
transform: `translate(${cx} ${f2(cy - r - BROW_LIFT[key] + brow.dy)}) rotate(${brow.a})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mouth: Mouth = o.mouth;
|
||||||
|
if (mouth === "smile" && rig.mouthStyle !== "smile") mouth = rig.mouthStyle;
|
||||||
|
const shape = MOUTHS[mouth];
|
||||||
|
|
||||||
|
parts["arm-left"] = { transform: `translate(46 124) rotate(${f1(o.armL)})` };
|
||||||
|
parts["arm-right"] = { transform: `translate(154 124) rotate(${f1(-o.armR)})` };
|
||||||
|
parts["hand-right"] = { transform: `rotate(${f1(o.armR)})` };
|
||||||
|
const pivot = pivots(rig);
|
||||||
|
const rotate = (part: "tail" | "ear-left" | "ear-right", angle: number) => {
|
||||||
|
const p = pivot[part];
|
||||||
|
if (p) parts[part] = { transform: `rotate(${f1(angle)} ${p[0]} ${p[1]})` };
|
||||||
|
};
|
||||||
|
rotate("tail", o.tail);
|
||||||
|
rotate("ear-left", o.earL);
|
||||||
|
rotate("ear-right", o.earR);
|
||||||
|
const busy = moment === "working" || moment === "autopilot";
|
||||||
|
parts.bulb = { opacity: Number((busy ? 0.55 + 0.45 * Math.sin(t * 6) : 1).toFixed(2)) };
|
||||||
|
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
mouth: { d: shape.d, filled: shape.filled },
|
||||||
|
tongue: shape.tongue ?? "",
|
||||||
|
fx: (kind, index) => fxState(kind, index, t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pokes the Oo: a squish, a hop and happy eyes for a second. */
|
||||||
|
export function ooBoop(life: OoLife, t: number): void {
|
||||||
|
life.boopAt = t;
|
||||||
|
}
|
||||||
233
packages/core/src/oo-palette.ts
Normal file
233
packages/core/src/oo-palette.ts
Normal file
File diff suppressed because one or more lines are too long
204
packages/core/src/oo-rig.test.ts
Normal file
204
packages/core/src/oo-rig.test.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { OoAvatar } from "./oo-avatar.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_OO_AVATAR,
|
||||||
|
OO_EYE_ASYMMETRY_MIN_RATIO,
|
||||||
|
OO_EYEWEAR,
|
||||||
|
OO_RIG_EYES,
|
||||||
|
OO_SPECIES,
|
||||||
|
} from "./oo-avatar.js";
|
||||||
|
import { createOoLife, ooFrame, ooMomentForStatus, ooSeededRandom } from "./oo-motion.js";
|
||||||
|
import {
|
||||||
|
OO_DEFAULT_COLOR,
|
||||||
|
OO_PALETTE_HEXES,
|
||||||
|
ooIdentityShapeIndex,
|
||||||
|
resolveOoClassicShape,
|
||||||
|
resolveOoColor,
|
||||||
|
} from "./oo-palette.js";
|
||||||
|
import type { OoGroup, OoLod, OoMoment, OoShape } from "./oo-rig.js";
|
||||||
|
import { buildOoRig, OO_MOMENTS, ooLodForSize } from "./oo-rig.js";
|
||||||
|
|
||||||
|
function rigFor(avatar: Partial<OoAvatar>, moment: OoMoment = "idle", lod: OoLod = "full") {
|
||||||
|
return buildOoRig({
|
||||||
|
avatar: { ...DEFAULT_OO_AVATAR, ...avatar },
|
||||||
|
color: "#8B5CF6",
|
||||||
|
fallbackShape: 0,
|
||||||
|
moment,
|
||||||
|
lod,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPart(shapes: OoShape[], part: string): OoGroup | undefined {
|
||||||
|
for (const shape of shapes) {
|
||||||
|
if (shape.type !== "group") continue;
|
||||||
|
if (shape.part === part) return shape;
|
||||||
|
const nested = findPart(shape.children, part);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allParts(shapes: OoShape[], out: string[] = []): string[] {
|
||||||
|
for (const shape of shapes) {
|
||||||
|
if (shape.type !== "group") continue;
|
||||||
|
if (shape.part) out.push(shape.part);
|
||||||
|
allParts(shape.children, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function radiusOf(group: OoGroup | undefined): number {
|
||||||
|
const white = group?.children.find(
|
||||||
|
(shape) => shape.type === "circle" && shape.fill === "#FFFFFF",
|
||||||
|
);
|
||||||
|
return white?.type === "circle" ? white.r : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("oo rig", () => {
|
||||||
|
it.each(OO_SPECIES)("keeps the Oo eyes on the %s", (species) => {
|
||||||
|
const rig = rigFor({ species });
|
||||||
|
const big = radiusOf(findPart(rig.shapes, "eye-big"));
|
||||||
|
const small = radiusOf(findPart(rig.shapes, "eye-small"));
|
||||||
|
expect(big).toBe(OO_RIG_EYES.big.r);
|
||||||
|
expect(big / small).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(OO_EYEWEAR.filter((kind) => kind !== "none"))("draws %s eyewear", (eyewear) => {
|
||||||
|
const plain = rigFor({}).shapes;
|
||||||
|
const styled = rigFor({ eyewear }).shapes;
|
||||||
|
expect(JSON.stringify(styled).length).toBeGreaterThan(JSON.stringify(plain).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops arms, props and effects at small sizes", () => {
|
||||||
|
expect(ooLodForSize(24)).toBe("xs");
|
||||||
|
expect(ooLodForSize(40)).toBe("mid");
|
||||||
|
expect(ooLodForSize(96)).toBe("full");
|
||||||
|
const xs = allParts(rigFor({}, "thinking", "xs").shapes);
|
||||||
|
expect(xs).not.toContain("arm-left");
|
||||||
|
expect(xs).not.toContain("fx");
|
||||||
|
const mid = allParts(rigFor({}, "thinking", "mid").shapes);
|
||||||
|
expect(mid).toContain("arm-left");
|
||||||
|
expect(mid).not.toContain("fx");
|
||||||
|
expect(allParts(rigFor({}, "thinking").shapes)).toContain("fx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the chef hat on for cooking unless a hat is worn", () => {
|
||||||
|
const cooking = JSON.stringify(rigFor({}, "cooking").shapes);
|
||||||
|
const crowned = JSON.stringify(rigFor({ head: "crown" }, "cooking").shapes);
|
||||||
|
expect(cooking).toContain("#D9D3DE");
|
||||||
|
expect(crowned).not.toContain("#D9D3DE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives ears and tails a pivot", () => {
|
||||||
|
const cat = rigFor({ species: "cat" });
|
||||||
|
expect(findPart(cat.shapes, "tail")?.pivot).toBeDefined();
|
||||||
|
expect(findPart(cat.shapes, "ear-left")?.pivot).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("oo motion", () => {
|
||||||
|
it.each(OO_MOMENTS)("draws a deterministic %s frame", (moment) => {
|
||||||
|
const rig = rigFor({ species: "dog", playful: 0.9 }, moment);
|
||||||
|
const run = () => {
|
||||||
|
const life = createOoLife(ooSeededRandom(7));
|
||||||
|
const avatar = { ...DEFAULT_OO_AVATAR, species: "dog" as const, playful: 0.9 };
|
||||||
|
const frames = [0.5, 3, 6.2].map((t) => ooFrame({ rig, avatar, moment, t, life }));
|
||||||
|
return frames.map((frame) => ({
|
||||||
|
parts: frame.parts,
|
||||||
|
mouth: frame.mouth,
|
||||||
|
fx: frame.fx("spark", 1),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
expect(run()).toEqual(run());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the eyes while asleep and smiles them shut for success", () => {
|
||||||
|
const life = createOoLife(ooSeededRandom(1));
|
||||||
|
const asleep = ooFrame({
|
||||||
|
rig: rigFor({}, "asleep"),
|
||||||
|
avatar: DEFAULT_OO_AVATAR,
|
||||||
|
moment: "asleep",
|
||||||
|
t: 1,
|
||||||
|
life,
|
||||||
|
still: true,
|
||||||
|
});
|
||||||
|
expect(asleep.parts["eye-big"]?.hidden).toBe(true);
|
||||||
|
expect(asleep.parts["closed-big"]?.hidden).toBe(false);
|
||||||
|
const success = ooFrame({
|
||||||
|
rig: rigFor({}, "success"),
|
||||||
|
avatar: DEFAULT_OO_AVATAR,
|
||||||
|
moment: "success",
|
||||||
|
t: 1,
|
||||||
|
life,
|
||||||
|
still: true,
|
||||||
|
});
|
||||||
|
expect(success.parts["happy-small"]?.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blinks on its own but never in still frames", () => {
|
||||||
|
const rig = rigFor({});
|
||||||
|
const life = createOoLife(ooSeededRandom(3));
|
||||||
|
let blinked = false;
|
||||||
|
for (let t = 0; t < 12; t += 1 / 30) {
|
||||||
|
const frame = ooFrame({ rig, avatar: DEFAULT_OO_AVATAR, moment: "idle", t, life });
|
||||||
|
if (frame.parts["closed-big"]?.hidden === false) blinked = true;
|
||||||
|
}
|
||||||
|
expect(blinked).toBe(true);
|
||||||
|
const still = createOoLife(ooSeededRandom(3));
|
||||||
|
for (let t = 0; t < 12; t += 1 / 30) {
|
||||||
|
const frame = ooFrame({
|
||||||
|
rig,
|
||||||
|
avatar: DEFAULT_OO_AVATAR,
|
||||||
|
moment: "idle",
|
||||||
|
t,
|
||||||
|
life: still,
|
||||||
|
still: true,
|
||||||
|
});
|
||||||
|
expect(frame.parts["closed-big"]?.hidden).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the cat mouth for a cat's smile", () => {
|
||||||
|
const life = createOoLife(ooSeededRandom(1));
|
||||||
|
const avatar = { ...DEFAULT_OO_AVATAR, species: "cat" as const };
|
||||||
|
const frame = ooFrame({
|
||||||
|
rig: rigFor({ species: "cat" }),
|
||||||
|
avatar,
|
||||||
|
moment: "idle",
|
||||||
|
t: 0.6,
|
||||||
|
life,
|
||||||
|
still: true,
|
||||||
|
});
|
||||||
|
expect(frame.mouth.d).toBe("M93 138Q98 144 103 138Q108 144 113 138");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps run status to moments", () => {
|
||||||
|
expect(ooMomentForStatus("running")).toBe("working");
|
||||||
|
expect(ooMomentForStatus("waiting_input")).toBe("waiting");
|
||||||
|
expect(ooMomentForStatus("failed")).toBe("concerned");
|
||||||
|
expect(ooMomentForStatus("idle", true)).toBe("speaking");
|
||||||
|
expect(ooMomentForStatus(undefined)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("oo palette", () => {
|
||||||
|
it("exposes the violet identity color as the shared default", () => {
|
||||||
|
expect(OO_PALETTE_HEXES).toContain(OO_DEFAULT_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves explicit colors and shapes", () => {
|
||||||
|
expect(resolveOoColor("bot", "#10B981").hex.toLowerCase()).toBe("#10b981");
|
||||||
|
expect(resolveOoColor("bot", "#fff").hex).toBe("#fff");
|
||||||
|
expect(resolveOoClassicShape("bot", "hex")).toContain("M");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the identity palette for invalid custom hex", () => {
|
||||||
|
expect(resolveOoColor("bot", "#zzzzzz")).toEqual(resolveOoColor("bot"));
|
||||||
|
expect(resolveOoColor("bot", "#ggg")).toEqual(resolveOoColor("bot"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives each identity a stable classic shape", () => {
|
||||||
|
expect(ooIdentityShapeIndex("maya")).toBe(ooIdentityShapeIndex("maya"));
|
||||||
|
expect(ooIdentityShapeIndex("maya")).toBeLessThan(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
1549
packages/core/src/oo-rig.ts
Normal file
1549
packages/core/src/oo-rig.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,7 @@
|
|||||||
import { ACTIVE_RUN_STATUSES } from "@rakazo/core";
|
import { ACTIVE_RUN_STATUSES, OO_DEFAULT_COLOR } from "@rakazo/core";
|
||||||
import { renderToString } from "react-dom/server";
|
import { renderToString } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { BotAvatar, OoShapePreview, ooMomentForPose, parseBotAvatar } from "./bot-avatar.js";
|
||||||
BotAvatar,
|
|
||||||
DEFAULT_GROK_BOT_COLOR,
|
|
||||||
GROK_BOT_COLORS,
|
|
||||||
GrokShapePreview,
|
|
||||||
parseBotAvatar,
|
|
||||||
resolvePersonaColorDef,
|
|
||||||
resolvePersonaShape,
|
|
||||||
} from "./bot-avatar.js";
|
|
||||||
|
|
||||||
describe("BotAvatar", () => {
|
describe("BotAvatar", () => {
|
||||||
it("renders distinct shape gradient IDs for concurrent working avatars", () => {
|
it("renders distinct shape gradient IDs for concurrent working avatars", () => {
|
||||||
@ -20,7 +12,7 @@ describe("BotAvatar", () => {
|
|||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const bodyGradIds = [...html.matchAll(/id="(evoo-body-[^"]+)"/g)].map((m) => m[1]);
|
const bodyGradIds = [...html.matchAll(/id="(oo[^"]+-body)"/g)].map((m) => m[1]);
|
||||||
expect(bodyGradIds).toHaveLength(2);
|
expect(bodyGradIds).toHaveLength(2);
|
||||||
expect(new Set(bodyGradIds).size).toBe(2);
|
expect(new Set(bodyGradIds).size).toBe(2);
|
||||||
for (const id of bodyGradIds) {
|
for (const id of bodyGradIds) {
|
||||||
@ -39,16 +31,43 @@ describe("BotAvatar", () => {
|
|||||||
expect(html).toContain('data-working="false"');
|
expect(html).toContain('data-working="false"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a geometric mascot for plain color values", () => {
|
it("draws the Oo eyes for plain color values", () => {
|
||||||
const html = renderToString(
|
const html = renderToString(
|
||||||
<BotAvatar color="#D9508A" identity="maya" size={28} status="running" />,
|
<BotAvatar color="#D9508A" identity="maya" size={28} status="running" />,
|
||||||
);
|
);
|
||||||
expect(html).toContain("<svg");
|
expect(html).toContain("<svg");
|
||||||
expect(html).toContain("<path");
|
expect(html).toContain("<path");
|
||||||
expect(html).toContain("<ellipse");
|
expect(html).toContain('data-part="eye-big"');
|
||||||
|
expect(html).toContain('data-part="eye-small"');
|
||||||
|
expect(html).toContain('data-oo-moment="working"');
|
||||||
expect(html).toContain('data-working="true"');
|
expect(html).toContain('data-working="true"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("draws the stored look", () => {
|
||||||
|
const html = renderToString(
|
||||||
|
<BotAvatar color="oo1:c=FF781C;sp=cat;ew=round;hd=chef" size={72} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain('data-oo-species="cat"');
|
||||||
|
expect(html).toContain('data-part="ear-left"');
|
||||||
|
expect(html).toContain("#FFFFFF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps small avatars free of arms and props", () => {
|
||||||
|
const small = renderToString(<BotAvatar color="#8B5CF6" status="running" size={24} />);
|
||||||
|
const large = renderToString(<BotAvatar color="#8B5CF6" status="running" size={72} />);
|
||||||
|
expect(small).not.toContain('data-part="arm-left"');
|
||||||
|
expect(large).toContain('data-part="arm-left"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps mascot poses to moments", () => {
|
||||||
|
expect(ooMomentForPose("delighted")).toBe("success");
|
||||||
|
expect(ooMomentForPose("typing")).toBe("working");
|
||||||
|
expect(ooMomentForPose("error")).toBe("concerned");
|
||||||
|
expect(ooMomentForPose("listening")).toBe("listening");
|
||||||
|
const html = renderToString(<BotAvatar color="#8B5CF6" pose="thinking" size={72} />);
|
||||||
|
expect(html).toContain('data-oo-moment="thinking"');
|
||||||
|
});
|
||||||
|
|
||||||
it("renders distinct shapes for distinct bot identities", () => {
|
it("renders distinct shapes for distinct bot identities", () => {
|
||||||
const maya = renderToString(<BotAvatar color="#D9508A" identity="maya" />);
|
const maya = renderToString(<BotAvatar color="#D9508A" identity="maya" />);
|
||||||
const github = renderToString(<BotAvatar color="#D9508A" identity="github" />);
|
const github = renderToString(<BotAvatar color="#D9508A" identity="github" />);
|
||||||
@ -56,33 +75,16 @@ describe("BotAvatar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses shape indexes from encoded color values", () => {
|
it("parses shape indexes from encoded color values", () => {
|
||||||
const parsed = parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_3`);
|
const parsed = parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_3`);
|
||||||
expect(parsed.color).toBe(DEFAULT_GROK_BOT_COLOR);
|
expect(parsed.color).toBe(OO_DEFAULT_COLOR);
|
||||||
expect(parsed.shapeIndex).toBe(3);
|
expect(parsed.shapeIndex).toBe(3);
|
||||||
expect(parsed.isImage).toBe(false);
|
expect(parsed.isImage).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes malformed shape suffixes to shape 0", () => {
|
it("normalizes malformed shape suffixes to shape 0", () => {
|
||||||
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_-1`).shapeIndex).toBe(0);
|
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_-1`).shapeIndex).toBe(0);
|
||||||
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_3junk`).shapeIndex).toBe(0);
|
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_3junk`).shapeIndex).toBe(0);
|
||||||
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_`).shapeIndex).toBe(0);
|
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_`).shapeIndex).toBe(0);
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the violet identity color as the shared default", () => {
|
|
||||||
expect(GROK_BOT_COLORS).toContain(DEFAULT_GROK_BOT_COLOR);
|
|
||||||
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_0`).color).toBe(DEFAULT_GROK_BOT_COLOR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves explicit colors and shapes", () => {
|
|
||||||
expect(resolvePersonaColorDef("bot", "#10B981").hex.toLowerCase()).toBe("#10b981");
|
|
||||||
expect(resolvePersonaColorDef("bot", "#fff").hex).toBe("#fff");
|
|
||||||
expect(resolvePersonaShape("bot", "hex")).toContain("M");
|
|
||||||
expect(GROK_BOT_COLORS.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the identity palette for invalid custom hex", () => {
|
|
||||||
expect(resolvePersonaColorDef("bot", "#zzzzzz")).toEqual(resolvePersonaColorDef("bot"));
|
|
||||||
expect(resolvePersonaColorDef("bot", "#ggg")).toEqual(resolvePersonaColorDef("bot"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders uploaded images without the geometric svg", () => {
|
it("renders uploaded images without the geometric svg", () => {
|
||||||
@ -91,7 +93,7 @@ describe("BotAvatar", () => {
|
|||||||
);
|
);
|
||||||
expect(html).toContain("<img");
|
expect(html).toContain("<img");
|
||||||
expect(html).not.toContain("<path");
|
expect(html).not.toContain("<path");
|
||||||
expect(html).not.toContain("grok-character-eyes");
|
expect(html).not.toContain("data-part");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat arbitrary http(s) color values as remote images", () => {
|
it("does not treat arbitrary http(s) color values as remote images", () => {
|
||||||
@ -105,17 +107,9 @@ describe("BotAvatar", () => {
|
|||||||
expect(html).not.toContain("evil.example");
|
expect(html).not.toContain("evil.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors reduced-motion for the working mascot pulse class", () => {
|
|
||||||
const html = renderToString(
|
|
||||||
<BotAvatar color="#8B5CF6" identity="maya" size={32} status="running" />,
|
|
||||||
);
|
|
||||||
expect(html).toContain("animate-pulse");
|
|
||||||
expect(html).toContain("motion-reduce:animate-none");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes shape picker name and pressed state", () => {
|
it("exposes shape picker name and pressed state", () => {
|
||||||
const html = renderToString(
|
const html = renderToString(
|
||||||
<GrokShapePreview shapeIndex={0} color="#8B5CF6" selected onClick={() => undefined} />,
|
<OoShapePreview shapeIndex={0} color="#8B5CF6" selected onClick={() => undefined} />,
|
||||||
);
|
);
|
||||||
expect(html).toContain('aria-label="EvOo"');
|
expect(html).toContain('aria-label="EvOo"');
|
||||||
expect(html).toContain('aria-pressed="true"');
|
expect(html).toContain('aria-pressed="true"');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,17 +1,10 @@
|
|||||||
export { type AvatarStyle, AvatarStyleProvider, useAvatarStyle } from "./avatar-style.js";
|
export { type AvatarStyle, AvatarStyleProvider, useAvatarStyle } from "./avatar-style.js";
|
||||||
export {
|
export {
|
||||||
BotAvatar,
|
BotAvatar,
|
||||||
DEFAULT_GROK_BOT_COLOR,
|
type BotAvatarProps,
|
||||||
GROK_BOT_COLORS,
|
OoShapePreview,
|
||||||
GROK_COLOR_LIST,
|
ooMomentForPose,
|
||||||
GROK_MASCOT_SHAPES,
|
|
||||||
GROK_SHAPES,
|
|
||||||
type GrokColorDef,
|
|
||||||
GrokShapePreview,
|
|
||||||
parseBotAvatar,
|
parseBotAvatar,
|
||||||
resolvePersonaColorDef,
|
|
||||||
resolvePersonaShape,
|
|
||||||
SHIPPED_SHAPE_KEYS,
|
|
||||||
} from "./bot-avatar.js";
|
} from "./bot-avatar.js";
|
||||||
export * from "./components/ui/alert-dialog.js";
|
export * from "./components/ui/alert-dialog.js";
|
||||||
export * from "./components/ui/badge.js";
|
export * from "./components/ui/badge.js";
|
||||||
@ -63,3 +56,4 @@ export { POSE_DEFINITIONS } from "./evoo-character/index.js";
|
|||||||
export { GroupAvatar, type GroupAvatarMember, type GroupAvatarProps } from "./group-avatar.js";
|
export { GroupAvatar, type GroupAvatarMember, type GroupAvatarProps } from "./group-avatar.js";
|
||||||
export { cn } from "./lib/utils.js";
|
export { cn } from "./lib/utils.js";
|
||||||
export { ModelThinkingOptions } from "./model-thinking-options.js";
|
export { ModelThinkingOptions } from "./model-thinking-options.js";
|
||||||
|
export { OoFigure, type OoFigureProps } from "./oo-figure.js";
|
||||||
|
|||||||
281
packages/ui-web/src/oo-figure.tsx
Normal file
281
packages/ui-web/src/oo-figure.tsx
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
|
||||||
|
import {
|
||||||
|
buildOoRig,
|
||||||
|
createOoLife,
|
||||||
|
OO_RIG_INSET,
|
||||||
|
OO_RIG_SIZE,
|
||||||
|
OO_STILL_TIME,
|
||||||
|
ooFrame,
|
||||||
|
ooLodForSize,
|
||||||
|
ooSeededRandom,
|
||||||
|
} from "@rakazo/core";
|
||||||
|
import { type ReactNode, 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;
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
/* ---------- 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,
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg || still || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
|
||||||
|
return;
|
||||||
|
if (!lifeRef.current) lifeRef.current = createOoLife();
|
||||||
|
const life = lifeRef.current;
|
||||||
|
const start = performance.now() / 1000 - Math.random() * 10;
|
||||||
|
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, still]);
|
||||||
|
|
||||||
|
const { colors } = rig;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={OO_VIEWBOX}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
data-oo-species={avatar.species}
|
||||||
|
data-oo-moment={moment}
|
||||||
|
overflow="visible"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user