agent-robot-avatar 0.3.2 → 0.6.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/CHANGELOG.md +209 -124
- package/LICENSE +21 -21
- package/README.md +198 -446
- package/agent-robot-avatar.js +27 -25
- package/docs/README.de.md +185 -166
- package/docs/README.es.md +185 -225
- package/docs/README.fr.md +185 -225
- package/docs/README.ja.md +185 -225
- package/docs/README.ko.md +185 -225
- package/docs/README.pt.md +185 -225
- package/docs/README.ru.md +185 -0
- package/docs/README.zh-CN.md +202 -396
- package/docs/README.zh-TW.md +185 -227
- package/index.d.ts +141 -111
- package/package.json +79 -77
- package/src/agent-robot-avatar-actions.js +403 -400
- package/src/agent-robot-avatar-antenna.js +152 -152
- package/src/agent-robot-avatar-core.js +1155 -1152
- package/src/agent-robot-avatar-extension-host.js +78 -78
- package/src/agent-robot-avatar-failure.js +131 -131
- package/src/agent-robot-avatar-geometry.js +40 -40
- package/src/agent-robot-avatar-gestures.js +170 -0
- package/src/agent-robot-avatar-head-roundness.js +103 -103
- package/src/agent-robot-avatar-inspect.js +242 -242
- package/src/agent-robot-avatar-love.js +226 -0
- package/src/agent-robot-avatar-random.js +272 -0
- package/src/agent-robot-avatar-runtime.js +948 -860
- package/src/agent-robot-avatar-version.js +3 -3
- package/src/agent-robot-avatar-waiting.js +154 -121
- package/src/agent-robot-avatar-wrap.js +66 -0
- package/src/agent-robot-avatar-antenna-flash.js +0 -127
|
@@ -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;
|