agent-robot-avatar 0.3.1 → 0.5.2

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.
@@ -0,0 +1,226 @@
1
+ import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
2
+
3
+ const proto = AgentRobotAvatar.prototype;
4
+
5
+ const clamp01 = value => Math.max(0, Math.min(1, value));
6
+ const lerp = (a, b, t) => a + (b - a) * t;
7
+ const smooth = t => {
8
+ t = clamp01(t);
9
+ return t * t * (3 - 2 * t);
10
+ };
11
+
12
+ // At full affection the two eyes become upright ovals that lean toward each
13
+ // other and overlap at the bottom, so together they read as one heart with a
14
+ // rounded base. Each oval is rotated about its own bottom point.
15
+ //
16
+ // rx / ry half width / half height of each oval
17
+ // tilt degrees each oval leans inward at the bottom (larger = lobes further apart)
18
+ // cross how far the two bottoms overlap past the centre line
19
+ // centerY vertical centre of the finished heart (head centre is 120)
20
+ // pulse heartbeat swell, as a fraction of size
21
+ // swayAngle / swayLift head lean in degrees / lift in px while the heart is held
22
+ const LOVE_DEFAULTS = Object.freeze({
23
+ rx: 28,
24
+ ry: 43,
25
+ tilt: 42,
26
+ cross: 13,
27
+ centerY: 126,
28
+ pulse: 0.155,
29
+ prep: 120,
30
+ morphIn: 240,
31
+ beat: 760,
32
+ beats: 3,
33
+ morphOut: 360,
34
+ swayAngle: 0,
35
+ swayLift: 2,
36
+ });
37
+
38
+ const LOVE_BOUNDS = Object.freeze({
39
+ rx: [10, 50],
40
+ ry: [20, 70],
41
+ tilt: [0, 60],
42
+ cross: [-10, 20],
43
+ centerY: [90, 150],
44
+ pulse: [0, 0.3],
45
+ prep: [0, 1200],
46
+ morphIn: [40, 1500],
47
+ beat: [200, 2000],
48
+ beats: [1, 60],
49
+ morphOut: [40, 1500],
50
+ swayAngle: [0, 8],
51
+ swayLift: [0, 8],
52
+ });
53
+
54
+ const runtimeWindow = typeof window !== 'undefined' ? window : null;
55
+ const loveConfig = (runtimeWindow?.AgentRobotAvatarLoveConfig && typeof runtimeWindow.AgentRobotAvatarLoveConfig === 'object')
56
+ ? runtimeWindow.AgentRobotAvatarLoveConfig
57
+ : {};
58
+
59
+ function normalizeConfigValue(key, value) {
60
+ const fallback = LOVE_DEFAULTS[key];
61
+ const numeric = Number(value);
62
+ if (!Number.isFinite(numeric)) return fallback;
63
+ const [min, max] = LOVE_BOUNDS[key];
64
+ return Math.max(min, Math.min(max, numeric));
65
+ }
66
+
67
+ if (runtimeWindow) {
68
+ runtimeWindow.AgentRobotAvatarLoveConfig = loveConfig;
69
+ runtimeWindow.AgentRobotAvatarLoveDefaults = LOVE_DEFAULTS;
70
+ runtimeWindow.AgentRobotAvatarLoveBounds = LOVE_BOUNDS;
71
+ }
72
+
73
+ function currentLoveConfig() {
74
+ const config = {};
75
+ for (const key of Object.keys(LOVE_DEFAULTS)) config[key] = normalizeConfigValue(key, loveConfig[key]);
76
+ config.beats = Math.round(config.beats);
77
+ return config;
78
+ }
79
+
80
+ function loveDuration(config) {
81
+ return config.morphIn + config.beat * config.beats + config.morphOut;
82
+ }
83
+
84
+ function reducedMotion(instance) {
85
+ const requested = String(instance.getAttribute('motion') || 'auto').trim().toLowerCase();
86
+ if (requested === 'reduce') return true;
87
+ if (requested === 'full') return false;
88
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
89
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
90
+ }
91
+
92
+ function dispatchLoveState(instance, state) {
93
+ instance.dispatchEvent(new CustomEvent('face-state', { detail: { state } }));
94
+ }
95
+
96
+ function animateLoveHead(instance, config, duration, elapsed = 0) {
97
+ if (!instance._headMotion?.animate) return;
98
+ const token = instance._transitionToken;
99
+ const { swayAngle, swayLift } = config;
100
+ instance._headMotion.style.transformBox = 'view-box';
101
+ instance._headMotion.style.transformOrigin = '120px 120px';
102
+ const anim = instance._headMotion.animate([
103
+ { transform: 'translateY(0px) rotate(0deg)', offset: 0, easing: 'cubic-bezier(.38,0,.25,1)' },
104
+ { transform: `translateY(${-swayLift}px) rotate(${-swayAngle}deg)`, offset: 0.3, easing: 'cubic-bezier(.35,0,.22,1)' },
105
+ { transform: `translateY(${-swayLift / 2}px) rotate(${swayAngle}deg)`, offset: 0.68, easing: 'cubic-bezier(.35,0,.22,1)' },
106
+ { transform: 'translateY(0px) rotate(0deg)', offset: 1 },
107
+ ], { duration, delay: -elapsed, easing: 'linear', fill: 'forwards' });
108
+ anim.onfinish = () => {
109
+ if (token === instance._transitionToken) instance._headMotion.style.transform = 'translateY(0px)';
110
+ };
111
+ }
112
+
113
+ proto.love = async function() {
114
+ this.noteActivity();
115
+ this._inputWanted = false;
116
+ const config = currentLoveConfig();
117
+ this.reset();
118
+ if (!(await this._prepareExpression({ normalizePose: true, duration: 160, pause: config.prep }))) return;
119
+
120
+ const token = this._transitionToken;
121
+ this._expressionLock = true;
122
+ this._look.x = 0;
123
+ this._look.y = 0;
124
+
125
+ const duration = loveDuration(config);
126
+ this._loveFx = { start: performance.now(), duration, timing: config, headStarted: false };
127
+ dispatchLoveState(this, 'love');
128
+ // Motion preference is read on every frame, so switching it mid-action
129
+ // takes effect immediately; the head sway starts from the first full-motion frame.
130
+ if (!reducedMotion(this)) {
131
+ this._loveFx.headStarted = true;
132
+ animateLoveHead(this, config, duration);
133
+ }
134
+
135
+ await this._wait(duration + 30);
136
+ if (token !== this._transitionToken) return;
137
+
138
+ this._loveFx = null;
139
+ this._look.x = 0;
140
+ this._look.y = 0;
141
+ this._releaseExpressionLock();
142
+ dispatchLoveState(this, 'idle');
143
+ };
144
+
145
+ function drawLove(now) {
146
+ const fx = this._loveFx;
147
+ if (!fx) return;
148
+
149
+ // Timing is fixed when the action starts; shape and pulse follow the live
150
+ // config so they can be tuned while the heart is on screen.
151
+ const timing = fx.timing;
152
+ const shape = currentLoveConfig();
153
+ const elapsed = Math.max(0, Math.min(fx.duration, now - fx.start));
154
+ const holdEnd = timing.morphIn + timing.beat * timing.beats;
155
+
156
+ const reduced = reducedMotion(this);
157
+ if (!reduced && !fx.headStarted) {
158
+ fx.headStarted = true;
159
+ animateLoveHead(this, timing, fx.duration, elapsed);
160
+ }
161
+
162
+ // 0 = idle eyes, 1 = full heart. Reduced motion shows the finished heart
163
+ // straight away and holds it, with no in-between frames or beat.
164
+ let morph = 1;
165
+ if (reduced) morph = 1;
166
+ else if (elapsed < timing.morphIn) morph = smooth(elapsed / timing.morphIn);
167
+ else if (elapsed > holdEnd) morph = 1 - smooth((elapsed - holdEnd) / timing.morphOut);
168
+
169
+ // Heartbeat: two soft swells per beat ("lub-dub"), only while held.
170
+ let pulse = 0;
171
+ if (!reduced && elapsed >= timing.morphIn && elapsed <= holdEnd) {
172
+ const phase = ((elapsed - timing.morphIn) % timing.beat) / timing.beat;
173
+ const swell = (centre, width) => Math.max(0, 1 - Math.abs(phase - centre) / width);
174
+ pulse = Math.max(smooth(swell(0.12, 0.12)), 0.6 * smooth(swell(0.36, 0.12)));
175
+ }
176
+ const scale = 1 + shape.pulse * pulse;
177
+
178
+ const tiltRad = shape.tilt * Math.PI / 180;
179
+ const baseY = shape.centerY + (shape.ry - 3) * Math.cos(tiltRad);
180
+ const beat = `translate(120 ${shape.centerY}) scale(${scale.toFixed(3)}) translate(-120 -${shape.centerY})`;
181
+
182
+ const eyes = [
183
+ [this._leftEye, this._leftBase, this._leftTop, this._leftBottom, 86, -1],
184
+ [this._rightEye, this._rightBase, this._rightTop, this._rightBottom, 154, 1],
185
+ ];
186
+ for (const [eye, base, top, bottom, restX, side] of eyes) {
187
+ const x = lerp(restX, 120 - side * shape.cross, morph);
188
+ const y = lerp(126, baseY, morph);
189
+ const tilt = side * shape.tilt * morph;
190
+ eye.setAttribute('transform', `${beat} translate(${x.toFixed(2)} ${y.toFixed(2)}) rotate(${tilt.toFixed(2)})`);
191
+
192
+ base.setAttribute('cx', '0');
193
+ base.setAttribute('cy', lerp(0, -(shape.ry - 3), morph).toFixed(2));
194
+ base.setAttribute('rx', lerp(27, shape.rx, morph).toFixed(2));
195
+ base.setAttribute('ry', lerp(29, shape.ry, morph).toFixed(2));
196
+ base.setAttribute('opacity', '1');
197
+
198
+ // Lids slide out of the way so they never cut the heart.
199
+ const topY = lerp(-36, -150, morph);
200
+ const bottomY = lerp(36, 150, morph);
201
+ top.setAttribute('y', (topY - 90).toFixed(2));
202
+ top.setAttribute('height', '90');
203
+ top.setAttribute('transform', `rotate(0 0 ${topY.toFixed(2)})`);
204
+ bottom.setAttribute('y', bottomY.toFixed(2));
205
+ bottom.setAttribute('height', '90');
206
+ bottom.setAttribute('transform', `rotate(0 0 ${bottomY.toFixed(2)})`);
207
+ }
208
+ }
209
+
210
+ registerAvatarExtension({
211
+ name: 'love',
212
+ actions: {
213
+ love() { return this.love(); },
214
+ },
215
+ beforePlay(action) {
216
+ if (action === 'love') return;
217
+ this._loveFx = null;
218
+ },
219
+ reset() {
220
+ this._loveFx = null;
221
+ },
222
+ draw: drawLove,
223
+ });
224
+
225
+ export { AgentRobotAvatar, LOVE_DEFAULTS };
226
+ export default AgentRobotAvatar;
@@ -0,0 +1,272 @@
1
+ import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
2
+
3
+ const proto = AgentRobotAvatar.prototype;
4
+ const clamp01 = value => Math.max(0, Math.min(1, value));
5
+ const smooth = t => {
6
+ t = clamp01(t);
7
+ return t * t * (3 - 2 * t);
8
+ };
9
+
10
+ // Each eye rolls over a drum that turns about a horizontal axis, like the reels
11
+ // of a slot machine. There is exactly one eye per side, projected the same way
12
+ // the wrap-waiting eyes are: position follows sin(angle) and height follows
13
+ // cos(angle), so the eye flattens to nothing as it rolls over the top or bottom
14
+ // edge instead of being cut off. Because only one eye ever exists on each
15
+ // reel, eyes can never overlap.
16
+ // The left reel slows, overshoots, bounces and settles, then the right reel
17
+ // follows. A settled reel is exactly the ordinary eye, so control returns to
18
+ // the regular eye with no visible jump.
19
+ //
20
+ // radius drum radius; how far the eye travels above and below its resting place
21
+ // gap extra travel, out of sight, between leaving the bottom and re-entering at the top
22
+ // squeeze extra width removed from the eye as it turns edge-on (optical correction)
23
+ // speed surface speed, units per millisecond
24
+ // ramp time to reach full speed
25
+ // spin how long the left reel spins at full speed before it starts to slow
26
+ // stagger how much later the right reel starts to slow
27
+ // slowDist distance covered while slowing down (larger = longer, softer stop)
28
+ // overshoot how far the reel passes its resting place before bouncing back
29
+ // bouncePeriod duration of one bounce oscillation
30
+ // damping 0-1; lower = more bounces
31
+ const RANDOM_DEFAULTS = Object.freeze({
32
+ radius: 50,
33
+ gap: 0,
34
+ squeeze: 12.5,
35
+ speed: 0.6,
36
+ ramp: 220,
37
+ spin: 1000,
38
+ stagger: 420,
39
+ slowDist: 40,
40
+ overshoot: 8,
41
+ bouncePeriod: 200,
42
+ damping: 0.3,
43
+ prep: 120,
44
+ });
45
+
46
+ const RANDOM_BOUNDS = Object.freeze({
47
+ radius: [50, 100],
48
+ gap: [0, 200],
49
+ squeeze: [0, 30],
50
+ speed: [0.15, 1.6],
51
+ ramp: [40, 800],
52
+ spin: [100, 4000],
53
+ stagger: [0, 1500],
54
+ slowDist: [40, 400],
55
+ overshoot: [0, 40],
56
+ bouncePeriod: [120, 800],
57
+ damping: [0.1, 0.9],
58
+ prep: [0, 1200],
59
+ });
60
+
61
+ const runtimeWindow = typeof window !== 'undefined' ? window : null;
62
+ const randomConfig = (runtimeWindow?.AgentRobotAvatarRandomConfig && typeof runtimeWindow.AgentRobotAvatarRandomConfig === 'object')
63
+ ? runtimeWindow.AgentRobotAvatarRandomConfig
64
+ : {};
65
+
66
+ function normalizeConfigValue(key, value) {
67
+ const fallback = RANDOM_DEFAULTS[key];
68
+ const numeric = Number(value);
69
+ if (!Number.isFinite(numeric)) return fallback;
70
+ const [min, max] = RANDOM_BOUNDS[key];
71
+ return Math.max(min, Math.min(max, numeric));
72
+ }
73
+
74
+ if (runtimeWindow) {
75
+ runtimeWindow.AgentRobotAvatarRandomConfig = randomConfig;
76
+ runtimeWindow.AgentRobotAvatarRandomDefaults = RANDOM_DEFAULTS;
77
+ runtimeWindow.AgentRobotAvatarRandomBounds = RANDOM_BOUNDS;
78
+ }
79
+
80
+ function currentRandomConfig() {
81
+ const config = {};
82
+ for (const key of Object.keys(RANDOM_DEFAULTS)) config[key] = normalizeConfigValue(key, randomConfig[key]);
83
+ return config;
84
+ }
85
+
86
+ // Reel position (in units, increasing = moving down) as a function of time.
87
+ // Phase 1: ramp up, then constant speed. Phase 2: Hermite slow-down that starts
88
+ // at the spin speed and ends at rest just past the target. Phase 3: damped
89
+ // bounce back onto the target.
90
+ function planReel(config, slowAt) {
91
+ const { speed: v, ramp, period: P, slowDist: L, overshoot: O, bouncePeriod, damping: zeta } = config;
92
+ const spinPos = t => (t < ramp ? v * t * t / (2 * ramp) : v * (t - ramp / 2));
93
+ const rampEnd = v * ramp / 2;
94
+
95
+ let n = Math.max(1, Math.round((spinPos(slowAt) + L - O) / P));
96
+ while (n * P + O - L < rampEnd) n += 1;
97
+ const target = n * P;
98
+ const pStop = target + O - L;
99
+ const tStop = pStop / v + ramp / 2;
100
+ const D = 2 * L / v;
101
+
102
+ const wd = 2 * Math.PI / bouncePeriod;
103
+ const w = wd / Math.sqrt(1 - zeta * zeta);
104
+ const decay = zeta * w;
105
+ const bounceLength = O > 0.1 ? Math.log(O / 0.1) / decay : 0;
106
+ const tLand = tStop + D;
107
+ const tEnd = tLand + bounceLength;
108
+
109
+ const position = t => {
110
+ if (t < tStop) return spinPos(t);
111
+ if (t < tLand) {
112
+ const u = (t - tStop) / D;
113
+ const h10 = u * u * u - 2 * u * u + u;
114
+ const h01 = -2 * u * u * u + 3 * u * u;
115
+ return pStop + L * (2 * h10 + h01);
116
+ }
117
+ if (t >= tEnd) return target;
118
+ const tau = t - tLand;
119
+ return target + O * Math.exp(-decay * tau) * (Math.cos(wd * tau) + (decay / wd) * Math.sin(wd * tau));
120
+ };
121
+
122
+ return { position, tEnd, tStop, tLand, period: P };
123
+ }
124
+
125
+ function reducedMotion(instance) {
126
+ const requested = String(instance.getAttribute('motion') || 'auto').trim().toLowerCase();
127
+ if (requested === 'reduce') return true;
128
+ if (requested === 'full') return false;
129
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
130
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
131
+ }
132
+
133
+ function dispatchRandomState(instance, state) {
134
+ instance.dispatchEvent(new CustomEvent('face-state', { detail: { state } }));
135
+ }
136
+
137
+ function buildRandomFx(config, start, extra = {}) {
138
+ // Reel position is measured as distance along the drum surface. One pass of
139
+ // the eye is the visible half of the drum plus the hidden gap.
140
+ const period = Math.PI * config.radius + config.gap;
141
+ const reel = { ...config, period };
142
+ const plans = [planReel(reel, config.spin), planReel(reel, config.spin + config.stagger)];
143
+ return {
144
+ start,
145
+ duration: Math.max(plans[0].tEnd, plans[1].tEnd),
146
+ plans,
147
+ period,
148
+ radius: config.radius,
149
+ squeeze: config.squeeze,
150
+ ...extra,
151
+ };
152
+ }
153
+
154
+ // Preview hook for the tuner page: renders the expression frozen at `elapsed`
155
+ // milliseconds using the live config and keeps it on screen (no timers, no
156
+ // lifecycle events). Pass null to leave the preview. Returns the duration.
157
+ proto._previewRandom = function(elapsed) {
158
+ if (elapsed === null) {
159
+ if (this._randomFx?.preview) {
160
+ this._randomFx = null;
161
+ this._releaseExpressionLock();
162
+ dispatchRandomState(this, 'idle');
163
+ }
164
+ return 0;
165
+ }
166
+ if (!this._randomFx?.preview) {
167
+ this.reset();
168
+ this._expressionLock = true;
169
+ this._look.x = 0;
170
+ this._look.y = 0;
171
+ dispatchRandomState(this, 'random');
172
+ }
173
+ this._randomFx = buildRandomFx(currentRandomConfig(), 0, { preview: true, frozenAt: elapsed });
174
+ this._resumeFrames?.();
175
+ return this._randomFx.duration;
176
+ };
177
+
178
+ proto.random = async function() {
179
+ this.noteActivity();
180
+ this._inputWanted = false;
181
+ const config = currentRandomConfig();
182
+ this.reset();
183
+ if (!(await this._prepareExpression({ normalizePose: true, duration: 160, pause: config.prep }))) return;
184
+
185
+ const token = this._transitionToken;
186
+ this._expressionLock = true;
187
+ this._look.x = 0;
188
+ this._look.y = 0;
189
+
190
+ this._randomFx = buildRandomFx(config, performance.now());
191
+ const duration = this._randomFx.duration;
192
+ dispatchRandomState(this, 'random');
193
+
194
+ await this._wait(duration + 30);
195
+ if (token !== this._transitionToken) return;
196
+
197
+ this._randomFx = null;
198
+ this._look.x = 0;
199
+ this._look.y = 0;
200
+ this._releaseExpressionLock();
201
+ dispatchRandomState(this, 'idle');
202
+ };
203
+
204
+ function drawRandom(now) {
205
+ const fx = this._randomFx;
206
+ if (!fx) return;
207
+
208
+ const elapsed = fx.frozenAt ?? Math.max(0, now - fx.start);
209
+ // Read live so a motion-preference change takes effect mid-action.
210
+ const reduced = fx.preview ? false : reducedMotion(this);
211
+ const { period: P, radius: R, squeeze } = fx;
212
+ const half = Math.PI * R / 2;
213
+
214
+ fx.plans.forEach((plan, index) => {
215
+ // Settled: leave the ordinary eye exactly as the regular draw left it.
216
+ if (elapsed >= plan.tEnd) return;
217
+
218
+ const base = index === 0 ? this._leftBase : this._rightBase;
219
+ const top = index === 0 ? this._leftTop : this._rightTop;
220
+ const bottom = index === 0 ? this._leftBottom : this._rightBottom;
221
+
222
+ // Reduced motion holds the eye part-way round instead of spinning.
223
+ const position = reduced ? P * 0.3 : plan.position(elapsed);
224
+ let offset = ((position % P) + P) % P;
225
+ if (offset > P / 2) offset -= P;
226
+
227
+ // Distance from the resting place, travelled along the drum surface. Beyond
228
+ // the visible half the eye is behind the drum and nothing is drawn.
229
+ const angle = Math.abs(offset) < half ? offset / R : Math.sign(offset) * Math.PI / 2;
230
+ const front = Math.abs(offset) < half ? Math.cos(angle) : 0;
231
+
232
+ // Same optical corrections as the wrap-waiting eyes, turned on their side:
233
+ // the eye gets slightly narrower and drifts toward the head edge as it
234
+ // rolls edge-on.
235
+ const side = 1 - front;
236
+ const width = 54 - squeeze * smooth(side);
237
+ const y = R * Math.sin(angle) + Math.sign(angle) * 6 * side ** 3;
238
+
239
+ base.setAttribute('cx', '0');
240
+ base.setAttribute('cy', y.toFixed(2));
241
+ base.setAttribute('rx', (width / 2).toFixed(2));
242
+ base.setAttribute('ry', (29 * front).toFixed(2));
243
+ base.setAttribute('opacity', '1');
244
+
245
+ // Eyelid masks stay out of the way while the eye is rolling.
246
+ top.setAttribute('y', '-240');
247
+ top.setAttribute('height', '90');
248
+ top.setAttribute('transform', 'rotate(0 0 -150)');
249
+ bottom.setAttribute('y', '150');
250
+ bottom.setAttribute('height', '90');
251
+ bottom.setAttribute('transform', 'rotate(0 0 150)');
252
+ });
253
+ }
254
+
255
+ registerAvatarExtension({
256
+ name: 'random',
257
+ actions: {
258
+ random() { return this.random(); },
259
+ slot() { return this.random(); },
260
+ },
261
+ beforePlay(action) {
262
+ if (action === 'random' || action === 'slot') return;
263
+ this._randomFx = null;
264
+ },
265
+ reset() {
266
+ this._randomFx = null;
267
+ },
268
+ draw: drawRandom,
269
+ });
270
+
271
+ export { AgentRobotAvatar, RANDOM_DEFAULTS };
272
+ export default AgentRobotAvatar;