aaavatars 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -13
- package/dist/Avatar.d.ts +14 -4
- package/dist/Avatar.js +160 -19
- package/dist/animations.d.ts +28 -3
- package/dist/animations.js +334 -99
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/parts.d.ts +6 -2
- package/dist/parts.js +107 -13
- package/dist/pointer.d.ts +1 -0
- package/dist/pointer.js +55 -13
- package/dist/styles.d.ts +1 -1
- package/dist/styles.js +234 -40
- package/dist/visibility.d.ts +1 -0
- package/dist/visibility.js +26 -0
- package/package.json +1 -1
- package/dist/value.d.ts +0 -20
- package/dist/value.js +0 -66
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# aaavatars
|
|
2
2
|
|
|
3
|
-
Deterministic 2D character avatars for AI agents. Same name, same character.
|
|
3
|
+
Deterministic 2D character avatars for AI agents. Same name, same character. Its state is four [layers](#layers): `activity`, `expression` and `gaze` drive looping states, expressed through the eyes, and `play()` triggers one-shot reactions.
|
|
4
4
|
|
|
5
5
|
[Live demo](https://rsmdt.github.io/aaavatars/)
|
|
6
6
|
|
|
@@ -11,13 +11,13 @@ npm install aaavatars
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
```tsx
|
|
14
|
-
import { Avatar, AvatarGroup, type AvatarHandle
|
|
14
|
+
import { Avatar, AvatarGroup, type AvatarHandle } from "aaavatars";
|
|
15
15
|
|
|
16
16
|
const avatar = useRef<AvatarHandle>(null);
|
|
17
17
|
|
|
18
|
-
<Avatar ref={avatar} name="Chief of Staff" size={36}
|
|
18
|
+
<Avatar ref={avatar} name="Chief of Staff" size={36} activity="coding" label="Writing code" />;
|
|
19
19
|
|
|
20
|
-
await avatar.current?.play("
|
|
20
|
+
await avatar.current?.play("success");
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Avatar
|
|
@@ -26,23 +26,122 @@ await avatar.current?.play("done");
|
|
|
26
26
|
|---|---|
|
|
27
27
|
| `name` | seed string |
|
|
28
28
|
| `frame` | `false` (no background), `0` (square) to `0.5` (circle) |
|
|
29
|
-
| `
|
|
29
|
+
| `activity` | `idle`, `thinking`, `working`, `reading`, `searching`, `reviewing`, `coding`, `writing`, `waiting`, `asking`, `listening`, `speaking`, `stuck`, `sleeping` |
|
|
30
|
+
| `expression` | `neutral`, `happy`, `sad`, `angry`, `confused`, `curious`, `tired`, `love` |
|
|
31
|
+
| `gaze` | `follow` (pointer), `ponder`, `scan`, `read`, `wander`, `ahead`, `down`, `away`; defaults per activity |
|
|
30
32
|
| `shape` | `circle`, `squircle`, `pill`, `hex`, `triangle`, `drop`, `cloud`, `blob` |
|
|
31
33
|
| `eyes` | `round`, `dot`, `tall`, `wide` |
|
|
32
34
|
| `mouth` | `none`, `smile`, `flat`, `open`, or `renderMouth={(slot) => <svg element />}` |
|
|
33
35
|
| `accessory` | `none`, `antenna`, `sprout`, `hat`, `glasses`, `bowtie` |
|
|
34
36
|
| `content` | `face` or `initial` |
|
|
35
37
|
| `colors` / `body` / `background` | palette to pick from, or explicit hex colours |
|
|
36
|
-
| `fill` / `shading` | `solid` or `gradient`; `0` to `1` |
|
|
38
|
+
| `fill` / `shading` | `solid` or `gradient`; soft-clay depth from `0` (flat, the default) to `1` |
|
|
37
39
|
| `src` | image URL, falls back to the character when it fails to load |
|
|
38
40
|
| `badge` | node pinned to the bottom-right corner |
|
|
39
41
|
| `label` | tooltip and accessible description of the current action |
|
|
40
|
-
| `blink` / `
|
|
42
|
+
| `blink` / `motion` | turn blinking or looping off |
|
|
41
43
|
| `interactive` | click, double-click, drag-to-squish and press-and-hold reactions |
|
|
44
|
+
| `onChange` | called with the event when one starts playing, from `play()` or an interactive reaction, and with `null` when it ends |
|
|
42
45
|
|
|
43
|
-
Unset `shape`, `eyes` and colours come from the name. `mouth` and `accessory` stay off until you set them.
|
|
46
|
+
Unset `shape`, `eyes` and colours come from the name. `mouth` and `accessory` stay off until you set them.
|
|
44
47
|
|
|
45
|
-
`
|
|
48
|
+
With `shading` above `0`, the head is shaded like soft clay: a darker rim, a sheen that rolls with the gaze, and a soft contact shadow that stays put while the body bobs.
|
|
49
|
+
|
|
50
|
+
## Layers
|
|
51
|
+
|
|
52
|
+
A character's state is four independent layers. Each answers one question, and each can change without touching the others.
|
|
53
|
+
|
|
54
|
+
| Layer | Answers | Lasts | Set with |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| Activity | what is the agent doing? | until changed | `activity` prop |
|
|
57
|
+
| Expression | how does it feel? | until changed | `expression` prop |
|
|
58
|
+
| Gaze | where is it looking? | until changed | `gaze` prop, defaults per activity |
|
|
59
|
+
| Event | what just happened? | about 1 to 3 seconds, then back to rest | `play(event)` |
|
|
60
|
+
|
|
61
|
+
The eyes carry every layer, so a face without a mouth still reads. Each eye has a top and bottom lid, and its outline can morph into a glyph.
|
|
62
|
+
|
|
63
|
+
When two layers want the same part of the face, the more specific one wins:
|
|
64
|
+
|
|
65
|
+
1. An event owns the whole face while it plays. Glyph eyes morph back to round eyes for it, except `error`, which morphs them into `x x`, and return when it ends.
|
|
66
|
+
2. A non-`neutral` expression owns the eyes, the blinking and, when a mouth is set, the mouth.
|
|
67
|
+
3. The activity owns the body motion, the accessory motion and, while the expression is `neutral`, the eye pose and the mouth.
|
|
68
|
+
4. The gaze moves where the eyes point. It never changes their shape.
|
|
69
|
+
|
|
70
|
+
`sleeping` is the one exception: it keeps the eyes closed whatever the expression.
|
|
71
|
+
|
|
72
|
+
### Activity
|
|
73
|
+
|
|
74
|
+
An activity is the body language of a task. It is not a combination of an expression and a gaze. It sets the body's motion, picks a default gaze, and, only while the expression is `neutral`, may give the eyes their own pose: a glyph, a lid shape or a loop. It never sets an emotion, so `activity="coding" expression="happy"` is a happy coder, with the happy eyes replacing the `>_` until the expression returns to `neutral`.
|
|
75
|
+
|
|
76
|
+
| Activity | Body | Eyes, with a `neutral` expression | Default gaze |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `idle` | slow drift | | `follow` |
|
|
79
|
+
| `thinking` | sway | | `ponder` |
|
|
80
|
+
| `working` | quick bob | | `scan` |
|
|
81
|
+
| `reading` | still | | `read` |
|
|
82
|
+
| `searching` | leans in | dart in short jumps and hold, looking for something | `ahead` |
|
|
83
|
+
| `reviewing` | head dips with the eyes | narrow to a slit, look down, up, back, then down once more; a 6s loop | `ahead` |
|
|
84
|
+
| `coding` | bob | morph into a sharp `>_` prompt with a blinking cursor | `ahead` |
|
|
85
|
+
| `writing` | slow bob | | `read` |
|
|
86
|
+
| `waiting` | still | settle to the centre, then circle around each other twice every 3.2s | `ahead` |
|
|
87
|
+
| `asking` | head tilted | still, one eye wider: the person's turn to answer or approve | `ahead` |
|
|
88
|
+
| `listening` | nod | slide together into one tall blinking cursor | `ahead` |
|
|
89
|
+
| `speaking` | bob, mouth talks | | `follow` |
|
|
90
|
+
| `stuck` | tilted | sweep side to side once, then morph into sharp `x x` crosses | `ahead` |
|
|
91
|
+
| `sleeping` | breathe, sunk | droop, catch themselves once, droop again and close. Leaving it wakes them: half-open, wide, back to rest | `ahead` |
|
|
92
|
+
|
|
93
|
+
### Expression
|
|
94
|
+
|
|
95
|
+
An expression is a mood that can last for minutes, shown through the lids and, when set, the mouth. A feeling that only makes sense for a moment, like surprise, is an event instead, so no value belongs to both layers.
|
|
96
|
+
|
|
97
|
+
| Expression | Eyes |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `neutral` | the activity's eyes |
|
|
100
|
+
| `happy` | bottom lids raised |
|
|
101
|
+
| `sad` | top lids down, tilted outward |
|
|
102
|
+
| `angry` | top lids down, tilted inward |
|
|
103
|
+
| `confused` | one lid lowered |
|
|
104
|
+
| `love` | both eyes tilt together into one beating heart |
|
|
105
|
+
| `tired` | heavy top lids, drooping outward |
|
|
106
|
+
| `curious` | both eyes slightly wider |
|
|
107
|
+
|
|
108
|
+
### Gaze
|
|
109
|
+
|
|
110
|
+
A gaze is a pattern of where to look. It works with any activity and any eye shape, and only moves the eyes. A loop that also needs the lids or the head, like `reviewing` or `waiting`, belongs to the activity instead.
|
|
111
|
+
|
|
112
|
+
| Gaze | Eyes |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `follow` | follow a mouse or pen anywhere on the page while it moves, then return to glancing 0.9s after it stops |
|
|
115
|
+
| `wander` | glance around and hold, ignoring the pointer |
|
|
116
|
+
| `ponder` | look up-left, then up-right, then back |
|
|
117
|
+
| `scan` | sweep across: slow right, quick jump back |
|
|
118
|
+
| `read` | read line by line: three short hops right, jump back and down a line, three lines, then back to the top |
|
|
119
|
+
| `ahead` | look straight out |
|
|
120
|
+
| `down` | look down at the work, drifting a little side to side |
|
|
121
|
+
| `away` | look off to the side, with a brief check-in straight ahead |
|
|
122
|
+
|
|
123
|
+
### Event
|
|
124
|
+
|
|
125
|
+
An event is a one-off reaction. `play(event)` resolves when the animation ends. Each reaction first centres the eyes and settles for half a second, except when triggered by a click or press.
|
|
126
|
+
|
|
127
|
+
An event does not change the `activity`, `expression` or `gaze` props. `EVENT_LAYERS[event]` names the expression and gaze it shows while it plays, for example `success` shows `happy` eyes looking `ahead`. Pair it with `onChange` to mirror the face in your own UI.
|
|
128
|
+
|
|
129
|
+
| Event | Reaction |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `acknowledge` | a double nod of the eyes |
|
|
132
|
+
| `handoff` | eyes and body lean to the side, then return |
|
|
133
|
+
| `success` | happy eyes with a long bounce |
|
|
134
|
+
| `warning` | a wince: the eyes shift aside, heavy lids press down twice, the head dips and rises |
|
|
135
|
+
| `error` | the eyes morph into `x x` crosses, then the head and eyes sink, hold, and recover |
|
|
136
|
+
| `surprised` | a squint, then wide eyes with a tremble |
|
|
137
|
+
| `sigh` | a small breath in, then the lids sink and the head slumps before it recovers |
|
|
138
|
+
| `yawn` | the head stretches tall while the eyes close, then a blink awake |
|
|
139
|
+
|
|
140
|
+
Under `prefers-reduced-motion`, or while the page is hidden, `play()` resolves immediately.
|
|
141
|
+
|
|
142
|
+
### Rendering
|
|
143
|
+
|
|
144
|
+
Each name blinks, glances and loops at its own tempo and phase, in CSS only. The `follow` gaze uses one shared pointer listener, updated once per frame. Offscreen avatars pause their loops. The glyphs (`>_`, `x x`, the cursor) are the eyes themselves, morphing their outline: smoothly in Chrome and Firefox, as an instant swap in Safari, which does not animate the SVG `d` property.
|
|
46
145
|
|
|
47
146
|
## AvatarGroup
|
|
48
147
|
|
|
@@ -50,10 +149,6 @@ Unset `shape`, `eyes` and colours come from the name. `mouth` and `accessory` st
|
|
|
50
149
|
<AvatarGroup max={4} size={28} members={[{ name: "Ada", content: "initial" }, { name: "Research" }]} />
|
|
51
150
|
```
|
|
52
151
|
|
|
53
|
-
## Stored value
|
|
54
|
-
|
|
55
|
-
`formatAvatarValue({ shape: "hex", accessory: "hat" })` gives `shape=hex;accessory=hat`. `parseAvatarValue(text)` returns `{ ok: true, value }` to spread onto `<Avatar>`, or `{ ok: false, error }` naming the invalid field.
|
|
56
|
-
|
|
57
152
|
## Static SVG
|
|
58
153
|
|
|
59
154
|
```ts
|
package/dist/Avatar.d.ts
CHANGED
|
@@ -2,14 +2,23 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { type AvatarEvent } from "./animations.js";
|
|
3
3
|
import { type RenderMouth } from "./parts.js";
|
|
4
4
|
import { type Accessory, type Eyes, type Fill, type Mouth, type Shape } from "./spec.js";
|
|
5
|
-
export declare const
|
|
6
|
-
export
|
|
5
|
+
export declare const ACTIVITIES: readonly ["idle", "thinking", "working", "reading", "searching", "reviewing", "coding", "writing", "waiting", "asking", "listening", "speaking", "stuck", "sleeping"];
|
|
6
|
+
export declare const EXPRESSIONS: readonly ["neutral", "happy", "sad", "angry", "confused", "curious", "tired", "love"];
|
|
7
|
+
export declare const GAZES: readonly ["follow", "ponder", "scan", "read", "wander", "ahead", "down", "away"];
|
|
8
|
+
export type Activity = (typeof ACTIVITIES)[number];
|
|
9
|
+
export type Expression = (typeof EXPRESSIONS)[number];
|
|
10
|
+
export type Gaze = (typeof GAZES)[number];
|
|
11
|
+
export declare const EVENT_LAYERS: Record<AvatarEvent, {
|
|
12
|
+
expression: Expression;
|
|
13
|
+
gaze: Gaze;
|
|
14
|
+
}>;
|
|
7
15
|
export type AvatarContent = "face" | "initial";
|
|
8
16
|
export interface AvatarHandle {
|
|
9
17
|
play: (event: AvatarEvent) => Promise<void>;
|
|
10
18
|
}
|
|
11
19
|
export interface AvatarProps {
|
|
12
20
|
accessory?: Accessory;
|
|
21
|
+
activity?: Activity;
|
|
13
22
|
background?: string;
|
|
14
23
|
badge?: ReactNode;
|
|
15
24
|
blink?: boolean;
|
|
@@ -17,21 +26,22 @@ export interface AvatarProps {
|
|
|
17
26
|
className?: string;
|
|
18
27
|
colors?: readonly string[];
|
|
19
28
|
content?: AvatarContent;
|
|
29
|
+
expression?: Expression;
|
|
20
30
|
eyes?: Eyes;
|
|
21
31
|
fill?: Fill;
|
|
22
32
|
frame?: false | number;
|
|
33
|
+
gaze?: Gaze;
|
|
23
34
|
interactive?: boolean;
|
|
24
35
|
label?: string;
|
|
25
|
-
look?: boolean;
|
|
26
36
|
motion?: boolean;
|
|
27
37
|
mouth?: Mouth;
|
|
28
38
|
name: string;
|
|
39
|
+
onChange?: (event: AvatarEvent | null) => void;
|
|
29
40
|
renderMouth?: RenderMouth;
|
|
30
41
|
shading?: number;
|
|
31
42
|
shape?: Shape;
|
|
32
43
|
size?: number;
|
|
33
44
|
src?: string;
|
|
34
|
-
status?: AvatarStatus;
|
|
35
45
|
}
|
|
36
46
|
export declare function frameRadius(frame: false | number | undefined): string | undefined;
|
|
37
47
|
export declare const Avatar: import("react").ForwardRefExoticComponent<AvatarProps & import("react").RefAttributes<AvatarHandle>>;
|
package/dist/Avatar.js
CHANGED
|
@@ -1,19 +1,70 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
3
|
-
import { EVENT_ANIMATIONS, MOOD_SWAP_REST, MOOD_SWAP_SHOW, } from "./animations.js";
|
|
3
|
+
import { EVENT_ANIMATIONS, MOOD_SWAP_REST, MOOD_SWAP_SHOW, PREP_MS, SLEEP_ANIMATION, STUCK_ANIMATION, WAKE_ANIMATION, stage, } from "./animations.js";
|
|
4
4
|
import { SHAPE_GEOMETRY } from "./geometry.js";
|
|
5
5
|
import { AccessoryShape, EyePair, MoodMouths, MouthShape, mouthSlot, } from "./parts.js";
|
|
6
6
|
import { createSpec, shadeHex, } from "./spec.js";
|
|
7
|
-
import { watchPointer } from "./pointer.js";
|
|
7
|
+
import { restartGazeCentred, watchPointer } from "./pointer.js";
|
|
8
8
|
import { AVATAR_CSS } from "./styles.js";
|
|
9
|
-
|
|
9
|
+
import { watchVisibility } from "./visibility.js";
|
|
10
|
+
export const ACTIVITIES = [
|
|
10
11
|
"idle",
|
|
11
12
|
"thinking",
|
|
12
13
|
"working",
|
|
14
|
+
"reading",
|
|
15
|
+
"searching",
|
|
16
|
+
"reviewing",
|
|
17
|
+
"coding",
|
|
18
|
+
"writing",
|
|
13
19
|
"waiting",
|
|
14
|
-
"
|
|
20
|
+
"asking",
|
|
21
|
+
"listening",
|
|
22
|
+
"speaking",
|
|
23
|
+
"stuck",
|
|
15
24
|
"sleeping",
|
|
16
25
|
];
|
|
26
|
+
export const EXPRESSIONS = [
|
|
27
|
+
"neutral",
|
|
28
|
+
"happy",
|
|
29
|
+
"sad",
|
|
30
|
+
"angry",
|
|
31
|
+
"confused",
|
|
32
|
+
"curious",
|
|
33
|
+
"tired",
|
|
34
|
+
"love",
|
|
35
|
+
];
|
|
36
|
+
export const GAZES = ["follow", "ponder", "scan", "read", "wander", "ahead", "down", "away"];
|
|
37
|
+
const ACTIVITY_GAZE = {
|
|
38
|
+
idle: "follow",
|
|
39
|
+
thinking: "ponder",
|
|
40
|
+
working: "scan",
|
|
41
|
+
reading: "read",
|
|
42
|
+
searching: "ahead",
|
|
43
|
+
reviewing: "ahead",
|
|
44
|
+
coding: "ahead",
|
|
45
|
+
writing: "read",
|
|
46
|
+
waiting: "ahead",
|
|
47
|
+
asking: "ahead",
|
|
48
|
+
listening: "ahead",
|
|
49
|
+
speaking: "follow",
|
|
50
|
+
stuck: "ahead",
|
|
51
|
+
sleeping: "ahead",
|
|
52
|
+
};
|
|
53
|
+
export const EVENT_LAYERS = {
|
|
54
|
+
acknowledge: { expression: "neutral", gaze: "ahead" },
|
|
55
|
+
handoff: { expression: "neutral", gaze: "ahead" },
|
|
56
|
+
success: { expression: "happy", gaze: "ahead" },
|
|
57
|
+
warning: { expression: "neutral", gaze: "ahead" },
|
|
58
|
+
error: { expression: "neutral", gaze: "ahead" },
|
|
59
|
+
surprised: { expression: "neutral", gaze: "ahead" },
|
|
60
|
+
sigh: { expression: "tired", gaze: "ahead" },
|
|
61
|
+
yawn: { expression: "tired", gaze: "ahead" },
|
|
62
|
+
};
|
|
63
|
+
const ACTIVITY_POSE = {
|
|
64
|
+
coding: "prompt",
|
|
65
|
+
stuck: "cross",
|
|
66
|
+
listening: "cursor",
|
|
67
|
+
};
|
|
17
68
|
const DRAG_THRESHOLD = 3;
|
|
18
69
|
const HOLD_MS = 600;
|
|
19
70
|
const SQUISH_LIMIT = 40;
|
|
@@ -21,6 +72,44 @@ function prefersReducedMotion() {
|
|
|
21
72
|
return (typeof window.matchMedia === "function" &&
|
|
22
73
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
23
74
|
}
|
|
75
|
+
function lidPairs(root, lids) {
|
|
76
|
+
if (!root || !lids)
|
|
77
|
+
return [];
|
|
78
|
+
return [...root.querySelectorAll(".aaa-lid")].map((lid) => {
|
|
79
|
+
const half = Number(lid.dataset.h);
|
|
80
|
+
const top = lid.classList.contains("aaa-lid-top");
|
|
81
|
+
const side = lid.classList.contains("aaa-lid-l") ? -1 : 1;
|
|
82
|
+
return [
|
|
83
|
+
lid,
|
|
84
|
+
lids.map(({ bottom, easing, offset, tilt = 0, top: drop }) => {
|
|
85
|
+
if (bottom === undefined && drop === undefined)
|
|
86
|
+
return { offset, ...(easing && { easing }) };
|
|
87
|
+
const transform = top
|
|
88
|
+
? `translateY(${((drop ?? 0) * half).toFixed(2)}px) rotate(${side * tilt}deg)`
|
|
89
|
+
: `translateY(${(-(bottom ?? 0) * half).toFixed(2)}px)`;
|
|
90
|
+
return { offset, transform, ...(easing && { easing }) };
|
|
91
|
+
}),
|
|
92
|
+
];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function eyePairs(root, animation) {
|
|
96
|
+
const frames = animation.eye;
|
|
97
|
+
if (!root || !frames)
|
|
98
|
+
return [];
|
|
99
|
+
return [...root.querySelectorAll(".aaa-eye")].map((eye) => [
|
|
100
|
+
eye,
|
|
101
|
+
frames(eye.classList.contains("aaa-eye-l") ? -1 : 1, {
|
|
102
|
+
gap: Number(eye.dataset.gap),
|
|
103
|
+
h: Number(eye.dataset.h),
|
|
104
|
+
w: Number(eye.dataset.w),
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
function heldValues(element, frames) {
|
|
109
|
+
const style = getComputedStyle(element);
|
|
110
|
+
const properties = new Set(frames.flatMap((frame) => Object.keys(frame).filter((key) => !["offset", "easing", "composite", "transform"].includes(key))));
|
|
111
|
+
return Object.fromEntries([...properties].map((property) => [property, style.getPropertyValue(property)]));
|
|
112
|
+
}
|
|
24
113
|
function clampFrame(frame) {
|
|
25
114
|
return Math.min(Math.max(frame, 0), 0.5);
|
|
26
115
|
}
|
|
@@ -29,7 +118,7 @@ export function frameRadius(frame) {
|
|
|
29
118
|
return undefined;
|
|
30
119
|
return `${clampFrame(frame ?? 0.5) * 100}%`;
|
|
31
120
|
}
|
|
32
|
-
export const Avatar = forwardRef(function Avatar({ accessory, background, badge, blink = true, body, className, colors, content = "face", eyes, fill = "solid", frame = 0.5, interactive = false, label,
|
|
121
|
+
export const Avatar = forwardRef(function Avatar({ accessory, activity = "idle", background, badge, blink = true, body, className, colors, content = "face", expression = "neutral", eyes, fill = "solid", frame = 0.5, gaze, interactive = false, label, motion = true, mouth, name, onChange, renderMouth, shading = 0, shape, size = 64, src, }, ref) {
|
|
33
122
|
const uid = `aaa-${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
|
|
34
123
|
const svgRef = useRef(null);
|
|
35
124
|
const bodyRef = useRef(null);
|
|
@@ -39,6 +128,10 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
|
|
|
39
128
|
const drag = useRef(null);
|
|
40
129
|
const suppressClick = useRef(false);
|
|
41
130
|
const [imageFailed, setImageFailed] = useState(false);
|
|
131
|
+
const [reacting, setReacting] = useState(null);
|
|
132
|
+
const latestOnChange = useRef(onChange);
|
|
133
|
+
latestOnChange.current = onChange;
|
|
134
|
+
const latestPlay = useRef(0);
|
|
42
135
|
useEffect(() => setImageFailed(false), [src]);
|
|
43
136
|
const spec = useMemo(() => createSpec(name, { accessory, background, body, colors, eyes, mouth, shape }), [name, accessory, background, body, colors, eyes, mouth, shape]);
|
|
44
137
|
const geometry = SHAPE_GEOMETRY[spec.shape];
|
|
@@ -47,13 +140,18 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
|
|
|
47
140
|
const showImage = Boolean(src) && !imageFailed;
|
|
48
141
|
const hasMouth = content === "face" && (Boolean(renderMouth) || spec.mouth !== "none");
|
|
49
142
|
const accessoryBehind = spec.accessory === "antenna" || spec.accessory === "sprout";
|
|
50
|
-
const
|
|
143
|
+
const resolvedGaze = gaze ?? ACTIVITY_GAZE[activity];
|
|
144
|
+
const shownExpression = activity === "sleeping" ? "neutral" : expression;
|
|
145
|
+
const pose = reacting ?? (shownExpression === "neutral" ? (ACTIVITY_POSE[activity] ?? "eye") : "eye");
|
|
146
|
+
const tracksPointer = resolvedGaze === "follow" && !showImage;
|
|
51
147
|
const slot = mouthSlot(geometry, spec.eyeColor);
|
|
52
148
|
const bodyFill = fill === "gradient" ? `url(#${uid}-fill)` : spec.body;
|
|
149
|
+
const clay = shading > 0;
|
|
150
|
+
const sheenY = geometry.top + (geometry.bottom - geometry.top) * 0.26;
|
|
53
151
|
const placement = framed
|
|
54
152
|
? `translate(${50 + spec.offsetX} ${50 + spec.offsetY}) rotate(${spec.rotate}) scale(0.78) translate(-50 -50)`
|
|
55
153
|
: "translate(50 50) scale(0.92) translate(-50 -50)";
|
|
56
|
-
const
|
|
154
|
+
const run = useCallback((name, animation, immediate = false) => {
|
|
57
155
|
for (const animation of running.current)
|
|
58
156
|
animation.cancel();
|
|
59
157
|
const target = bodyRef.current;
|
|
@@ -64,8 +162,8 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
|
|
|
64
162
|
prefersReducedMotion()) {
|
|
65
163
|
return Promise.resolve();
|
|
66
164
|
}
|
|
67
|
-
const
|
|
68
|
-
const timing = { duration: animation.duration, easing: "
|
|
165
|
+
const prep = immediate ? 0 : (animation.prep ?? PREP_MS);
|
|
166
|
+
const timing = { duration: prep + animation.duration, easing: "linear" };
|
|
69
167
|
const root = svgRef.current;
|
|
70
168
|
const rest = root?.querySelector(".aaa-mouth-rest");
|
|
71
169
|
const accessory = root?.querySelector(".aaa-accessory");
|
|
@@ -80,11 +178,50 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
|
|
|
80
178
|
[rest, animation.mood ? (animation.mood.rest ?? MOOD_SWAP_REST) : animation.mouth],
|
|
81
179
|
[mood, animation.mood ? (animation.mood.frames ?? MOOD_SWAP_SHOW) : undefined],
|
|
82
180
|
[accessory, accessoryFrames],
|
|
181
|
+
...lidPairs(root, animation.lids),
|
|
182
|
+
...eyePairs(root, animation),
|
|
83
183
|
];
|
|
84
|
-
|
|
85
|
-
|
|
184
|
+
const started = pairs.flatMap(([element, frames]) => element && frames?.length
|
|
185
|
+
? [element.animate(stage(frames, animation.duration, prep, heldValues(element, frames)), timing)]
|
|
186
|
+
: []);
|
|
187
|
+
running.current = started;
|
|
188
|
+
root?.setAttribute("data-reacting", name);
|
|
189
|
+
setReacting(animation.pose ?? "eye");
|
|
190
|
+
return Promise.all(started.map((animation) => animation.finished)).then(() => undefined, () => undefined).then(() => {
|
|
191
|
+
if (running.current !== started || !root)
|
|
192
|
+
return;
|
|
193
|
+
restartGazeCentred(root);
|
|
194
|
+
root.removeAttribute("data-reacting");
|
|
195
|
+
setReacting(null);
|
|
196
|
+
});
|
|
197
|
+
}, []);
|
|
198
|
+
const play = useCallback(async (event, immediate = false) => {
|
|
199
|
+
const id = ++latestPlay.current;
|
|
200
|
+
latestOnChange.current?.(event);
|
|
201
|
+
await run(event, EVENT_ANIMATIONS[event], immediate);
|
|
202
|
+
if (id === latestPlay.current)
|
|
203
|
+
latestOnChange.current?.(null);
|
|
204
|
+
}, [run]);
|
|
205
|
+
useImperativeHandle(ref, () => ({ play: (event) => play(event) }), [play]);
|
|
206
|
+
const previousActivity = useRef(activity);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const previous = previousActivity.current;
|
|
209
|
+
previousActivity.current = activity;
|
|
210
|
+
if (previous === activity)
|
|
211
|
+
return;
|
|
212
|
+
if (activity === "sleeping")
|
|
213
|
+
void run("sleep", SLEEP_ANIMATION);
|
|
214
|
+
else if (previous === "sleeping")
|
|
215
|
+
void run("wake", WAKE_ANIMATION);
|
|
216
|
+
else if (activity === "stuck")
|
|
217
|
+
void run("stuck", STUCK_ANIMATION);
|
|
218
|
+
}, [activity, play, run]);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const svg = svgRef.current;
|
|
221
|
+
if (!svg)
|
|
222
|
+
return;
|
|
223
|
+
return watchVisibility(svg);
|
|
86
224
|
}, []);
|
|
87
|
-
useImperativeHandle(ref, () => ({ play }), [play]);
|
|
88
225
|
useEffect(() => {
|
|
89
226
|
const svg = svgRef.current;
|
|
90
227
|
if (!tracksPointer || !svg || prefersReducedMotion())
|
|
@@ -102,7 +239,7 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
|
|
|
102
239
|
};
|
|
103
240
|
state.holdTimer = setTimeout(() => {
|
|
104
241
|
state.held = true;
|
|
105
|
-
void play("success");
|
|
242
|
+
void play("success", true);
|
|
106
243
|
}, HOLD_MS);
|
|
107
244
|
drag.current = state;
|
|
108
245
|
}
|
|
@@ -143,12 +280,16 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
|
|
|
143
280
|
suppressClick.current = false;
|
|
144
281
|
return;
|
|
145
282
|
}
|
|
146
|
-
void play(event.detail > 1 ? "
|
|
283
|
+
void play(event.detail > 1 ? "surprised" : "acknowledge", true);
|
|
147
284
|
}
|
|
148
|
-
const svg = (_jsxs("svg", { ref: svgRef, "aria-label": label ? `${name}, ${label}` : name, className: className && !badge ? `aaa ${className}` : "aaa", "data-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
285
|
+
const svg = (_jsxs("svg", { ref: svgRef, "aria-label": label ? `${name}, ${label}` : name, className: className && !badge ? `aaa ${className}` : "aaa", "data-activity": activity, "data-blink": blink ? undefined : "off", "data-expression": shownExpression, "data-gaze": resolvedGaze, "data-interactive": interactive ? "" : undefined, "data-motion": motion ? undefined : "off", height: size, overflow: framed ? undefined : "visible", role: "img", style: {
|
|
286
|
+
"--aaa-gap": geometry.eyeGap,
|
|
287
|
+
"--aaa-phase": spec.phase,
|
|
288
|
+
"--aaa-tempo": spec.tempo,
|
|
289
|
+
}, viewBox: "0 0 100 100", width: size, onClick: interactive ? handleClick : undefined, onPointerCancel: interactive ? endDrag : undefined, onPointerDown: interactive ? startDrag : undefined, onPointerMove: interactive ? moveDrag : undefined, onPointerUp: interactive ? endDrag : undefined, children: [label ? _jsx("title", { children: label }) : null, _jsx("style", { children: AVATAR_CSS }), _jsxs("defs", { children: [framed ? (_jsx("clipPath", { id: `${uid}-frame`, children: _jsx("rect", { width: 100, height: 100, rx: radius }) })) : null, fill === "gradient" ? (_jsxs("linearGradient", { id: `${uid}-fill`, x1: "0", y1: "0", x2: "1", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: shadeHex(spec.body, 0.18) }), _jsx("stop", { offset: "100%", stopColor: shadeHex(spec.body, -0.22) })] })) : null, clay ? (_jsxs(_Fragment, { children: [_jsx("clipPath", { id: `${uid}-body`, children: _jsx("path", { d: geometry.path }) }), _jsxs("mask", { id: `${uid}-outside`, maskUnits: "userSpaceOnUse", x: -20, y: -20, width: 140, height: 140, children: [_jsx("rect", { x: -20, y: -20, width: 140, height: 140, fill: "#fff" }), _jsx("path", { d: geometry.path, fill: "#000" })] }), _jsxs("radialGradient", { id: `${uid}-rim`, cx: "0.5", cy: "0.5", fx: "0.42", fy: "0.36", r: "0.5", children: [_jsx("stop", { offset: "45%", stopColor: "#000000", stopOpacity: 0 }), _jsx("stop", { offset: "80%", stopColor: "#000000", stopOpacity: 0.1 * shading }), _jsx("stop", { offset: "100%", stopColor: "#000000", stopOpacity: 0.3 * shading })] }), _jsxs("radialGradient", { id: `${uid}-sheen`, children: [_jsx("stop", { offset: "0%", stopColor: "#ffffff", stopOpacity: 0.6 * shading }), _jsx("stop", { offset: "100%", stopColor: "#ffffff", stopOpacity: 0 })] }), _jsxs("radialGradient", { id: `${uid}-shadow`, children: [_jsx("stop", { offset: "0%", stopColor: "#000000", stopOpacity: 0.3 * shading }), _jsx("stop", { offset: "100%", stopColor: "#000000", stopOpacity: 0 })] })] })) : null] }), framed ? (_jsx("rect", { "data-frame": "", width: 100, height: 100, rx: radius, fill: spec.background })) : null, _jsx("g", { clipPath: framed ? `url(#${uid}-frame)` : undefined, children: showImage ? (_jsx("g", { ref: bodyRef, className: "aaa-react", children: _jsx("image", { "data-image": "", height: 100, href: src, preserveAspectRatio: "xMidYMid slice", width: 100, onError: () => setImageFailed(true) }) })) : (_jsx("g", { transform: placement, children: _jsx("g", { ref: squishRef, className: "aaa-squish", children: _jsxs("g", { className: "aaa-head", children: [clay ? (_jsx("ellipse", { className: "aaa-shadow", cx: 50, cy: geometry.bottom + 5, fill: `url(#${uid}-shadow)`, rx: 30, ry: 5 })) : null, _jsx("g", { className: "aaa-body-motion", children: _jsxs("g", { ref: bodyRef, className: "aaa-react", children: [accessoryBehind ? (_jsx(AccessoryShape, { accent: spec.accent, accessory: spec.accessory, body: spec.body, geometry: geometry })) : null, _jsx("path", { d: geometry.path, fill: bodyFill, stroke: bodyFill, strokeLinejoin: "round", strokeWidth: 8 }), clay ? (_jsxs(_Fragment, { children: [_jsxs("g", { className: "aaa-rim", children: [_jsx("path", { d: geometry.path, fill: `url(#${uid}-rim)` }), _jsx("path", { d: geometry.path, fill: "none", mask: `url(#${uid}-outside)`, stroke: "#000000", strokeLinejoin: "round", strokeOpacity: 0.3 * shading, strokeWidth: 8 })] }), _jsx("g", { clipPath: `url(#${uid}-body)`, children: _jsx("g", { className: "aaa-look", children: _jsx("g", { className: "aaa-gaze", children: _jsx("ellipse", { className: "aaa-sheen", cx: 39, cy: sheenY, fill: `url(#${uid}-sheen)`, rx: 17, ry: 11, transform: `rotate(-24 39 ${sheenY})` }) }) }) })] })) : null, _jsx("g", { ref: eyesRef, className: "aaa-eyes-react", fill: spec.eyeColor, children: _jsx("g", { className: "aaa-look", children: content === "initial" ? (_jsx("text", { dominantBaseline: "central", fontFamily: "system-ui, sans-serif", fontSize: 38, fontWeight: 700, textAnchor: "middle", x: 50, y: (geometry.top + geometry.bottom) / 2 + 2, children: name.trim().charAt(0).toUpperCase() })) : (_jsx("g", { className: "aaa-gaze", children: _jsx("g", { className: "aaa-eyes-motion", children: _jsx("g", { className: "aaa-blink", style: {
|
|
290
|
+
animationDelay: `${spec.blinkDelay}s`,
|
|
291
|
+
animationDuration: `${spec.blinkDuration * 2}s`,
|
|
292
|
+
}, children: _jsx(EyePair, { eyes: spec.eyes, gap: geometry.eyeGap, id: uid, pose: pose, y: geometry.eyeY }) }) }) })) }) }), hasMouth ? (_jsxs("g", { className: "aaa-look", children: [_jsx("g", { className: "aaa-mouth-rest", children: renderMouth ? (renderMouth(slot)) : (_jsx(MouthShape, { mouth: spec.mouth, slot: slot })) }), _jsx(MoodMouths, { slot: slot })] })) : null, !accessoryBehind && (spec.accessory !== "glasses" || content === "face") ? (_jsx(AccessoryShape, { accent: spec.accent, accessory: spec.accessory, body: spec.body, geometry: geometry })) : null] }) })] }) }) })) })] }));
|
|
152
293
|
if (!badge)
|
|
153
294
|
return svg;
|
|
154
295
|
return (_jsxs("span", { className: className, style: {
|
package/dist/animations.d.ts
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
|
-
export declare const AVATAR_EVENTS: readonly ["acknowledge", "handoff", "
|
|
2
|
-
import type { Mood } from "./parts.js";
|
|
1
|
+
export declare const AVATAR_EVENTS: readonly ["acknowledge", "handoff", "success", "warning", "error", "surprised", "sigh", "yawn"];
|
|
2
|
+
import type { EyePose, Mood } from "./parts.js";
|
|
3
3
|
import type { Accessory } from "./spec.js";
|
|
4
4
|
export type AvatarEvent = (typeof AVATAR_EVENTS)[number];
|
|
5
5
|
type AccessoryFrames = Partial<Record<Exclude<Accessory, "none">, Keyframe[]>>;
|
|
6
|
-
interface
|
|
6
|
+
export interface LidFrame {
|
|
7
|
+
bottom?: number;
|
|
8
|
+
easing?: string;
|
|
9
|
+
offset: number;
|
|
10
|
+
tilt?: number;
|
|
11
|
+
top?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface EyeSize {
|
|
14
|
+
gap: number;
|
|
15
|
+
h: number;
|
|
16
|
+
w: number;
|
|
17
|
+
}
|
|
18
|
+
export interface EventAnimation {
|
|
7
19
|
accessory?: AccessoryFrames;
|
|
8
20
|
body: Keyframe[];
|
|
9
21
|
duration: number;
|
|
22
|
+
eye?: (side: -1 | 1, size: EyeSize) => Keyframe[];
|
|
10
23
|
eyes: Keyframe[];
|
|
24
|
+
lids?: LidFrame[];
|
|
11
25
|
mood?: {
|
|
12
26
|
frames?: Keyframe[];
|
|
13
27
|
name: Mood;
|
|
14
28
|
rest?: Keyframe[];
|
|
15
29
|
};
|
|
16
30
|
mouth?: Keyframe[];
|
|
31
|
+
pose?: EyePose;
|
|
32
|
+
prep?: number;
|
|
17
33
|
}
|
|
34
|
+
export declare const EASE_OUT = "cubic-bezier(.22,1,.36,1)";
|
|
35
|
+
export declare const EASE_IN_OUT = "cubic-bezier(.65,0,.35,1)";
|
|
36
|
+
export declare const EASE_HEAD = "cubic-bezier(.16,1,.3,1)";
|
|
37
|
+
export declare const EASE_SINE = "cubic-bezier(.37,0,.63,1)";
|
|
38
|
+
export declare const PREP_MS = 500;
|
|
39
|
+
export declare function stage(frames: Keyframe[], duration: number, prep: number, current?: Keyframe): Keyframe[];
|
|
18
40
|
export declare const MOOD_SWAP_REST: Keyframe[];
|
|
19
41
|
export declare const MOOD_SWAP_SHOW: Keyframe[];
|
|
20
42
|
export declare const EVENT_ANIMATIONS: Record<AvatarEvent, EventAnimation>;
|
|
43
|
+
export declare const WAKE_ANIMATION: EventAnimation;
|
|
44
|
+
export declare const SLEEP_ANIMATION: EventAnimation;
|
|
45
|
+
export declare const STUCK_ANIMATION: EventAnimation;
|
|
21
46
|
export {};
|