evOo-local/packages/core/src/oo-motion.ts
cynarAI 55df4a6439
feat(avatar): let Oos react to what their bots do (#141)
A bot's Oo now cheers for a moment when its run finishes, the stage Oo
follows the pointer and squishes when tapped, and chat headers show the
speaking bot's own Oo instead of the generic mascot. Small avatars in
lists only animate while working or waiting so long lists stay calm.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 01:51:37 +02:00

576 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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":
case "waiting_takeover":
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;
}
/** How long an Oo celebrates a finished run before it settles back to idle. */
export const OO_SUCCESS_SECONDS = 2.6;
/**
* The moment to show right after a status change: a run that just finished
* successfully earns a short celebration, everything else follows the status.
*/
export function ooMomentAfterStatus(
previous: string | undefined,
status: string | undefined,
): OoMoment | null {
const wasWorking = ooMomentForStatus(previous) === "working";
return wasWorking && (status === "idle" || status === "completed") ? "success" : null;
}