/** * 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, 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 = { 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>>(); function pivots(rig: OoRig): Partial> { 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; }