agent-robot-avatar 0.2.0 → 0.3.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.
@@ -1,7 +1,7 @@
1
1
  import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
2
+ import { DEFAULT_HEAD_HEIGHT_SCALE, flattenHeadPoints } from './agent-robot-avatar-geometry.js';
2
3
 
3
4
  const SVG_NS = 'http://www.w3.org/2000/svg';
4
- const HEAD_HEIGHT_SCALE = 0.9;
5
5
  const ANTENNA_DEFAULTS = Object.freeze({
6
6
  x: 120,
7
7
  y: 12,
@@ -35,40 +35,10 @@ function antennaHome() {
35
35
  };
36
36
  }
37
37
 
38
- function smooth01(t) {
39
- t = Math.max(0, Math.min(1, t));
40
- return t * t * (3 - 2 * t);
41
- }
42
-
43
38
  function flattenHead(instance) {
44
39
  if (instance._headFlattenedR32 || !instance._headShape || !instance._baseHeadPoints?.length) return;
45
40
 
46
- const source = instance._baseHeadPoints.map(point => ({ x: point.x, y: point.y }));
47
- const minY = Math.min(...source.map(point => point.y));
48
- const maxY = Math.max(...source.map(point => point.y));
49
- const height = maxY - minY;
50
- const centerY = (minY + maxY) / 2;
51
- const inset = height * (1 - HEAD_HEIGHT_SCALE) / 2;
52
-
53
- const upperLockY = minY + height * 0.40;
54
- const lowerLockY = maxY - height * 0.40;
55
-
56
- const flattened = source.map(point => {
57
- let shiftY = 0;
58
- if (point.y <= upperLockY) {
59
- shiftY = inset;
60
- } else if (point.y < centerY) {
61
- const t = (centerY - point.y) / Math.max(0.001, centerY - upperLockY);
62
- shiftY = inset * smooth01(t);
63
- } else if (point.y >= lowerLockY) {
64
- shiftY = -inset;
65
- } else if (point.y > centerY) {
66
- const t = (point.y - centerY) / Math.max(0.001, lowerLockY - centerY);
67
- shiftY = -inset * smooth01(t);
68
- }
69
- return { x: point.x, y: point.y + shiftY };
70
- });
71
-
41
+ const flattened = flattenHeadPoints(instance._baseHeadPoints, DEFAULT_HEAD_HEIGHT_SCALE);
72
42
  const path = typeof instance._pointsToPath === 'function'
73
43
  ? instance._pointsToPath(flattened)
74
44
  : `M ${flattened.map((point, index) => `${index ? 'L ' : ''}${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(' ')} Z`;
@@ -135,24 +105,33 @@ function updateAntenna(instance, now) {
135
105
  const dot = instance._antennaDot;
136
106
  const target = antennaTarget(instance);
137
107
 
138
- if (!spring.initialized) {
108
+ if (instance._isReducedMotion?.()) {
139
109
  spring.x = target.x;
140
110
  spring.y = target.y;
111
+ spring.vx = 0;
112
+ spring.vy = 0;
141
113
  spring.last = now;
142
114
  spring.initialized = true;
143
- }
115
+ } else {
116
+ if (!spring.initialized) {
117
+ spring.x = target.x;
118
+ spring.y = target.y;
119
+ spring.last = now;
120
+ spring.initialized = true;
121
+ }
122
+
123
+ const dt = Math.max(0.001, Math.min(0.05, (now - spring.last) / 1000 || 0.033));
124
+ spring.last = now;
144
125
 
145
- const dt = Math.max(0.001, Math.min(0.05, (now - spring.last) / 1000 || 0.033));
146
- spring.last = now;
147
-
148
- const stiffness = configNumber('stiffness', ANTENNA_DEFAULTS.stiffness, 5, 300);
149
- const damping = configNumber('damping', ANTENNA_DEFAULTS.damping, 0.5, 40);
150
- const ax = (target.x - spring.x) * stiffness - spring.vx * damping;
151
- const ay = (target.y - spring.y) * stiffness - spring.vy * damping;
152
- spring.vx += ax * dt;
153
- spring.vy += ay * dt;
154
- spring.x += spring.vx * dt;
155
- spring.y += spring.vy * dt;
126
+ const stiffness = configNumber('stiffness', ANTENNA_DEFAULTS.stiffness, 5, 300);
127
+ const damping = configNumber('damping', ANTENNA_DEFAULTS.damping, 0.5, 40);
128
+ const ax = (target.x - spring.x) * stiffness - spring.vx * damping;
129
+ const ay = (target.y - spring.y) * stiffness - spring.vy * damping;
130
+ spring.vx += ax * dt;
131
+ spring.vy += ay * dt;
132
+ spring.x += spring.vx * dt;
133
+ spring.y += spring.vy * dt;
134
+ }
156
135
 
157
136
  dot.setAttribute('cx', spring.x.toFixed(2));
158
137
  dot.setAttribute('cy', spring.y.toFixed(2));
@@ -142,6 +142,7 @@ const AgentRobotAvatar = (() => {
142
142
  this._inputWanted = false;
143
143
  this._sleeping = false;
144
144
  this._idleTimer = null;
145
+ this._pendingWaits = new Map();
145
146
  this._boredRoutine = null;
146
147
  this._boredLookSpeed = null;
147
148
  this._autoSleepMs = Number(this.getAttribute('auto-sleep')) || 0;
@@ -169,6 +170,7 @@ const AgentRobotAvatar = (() => {
169
170
  this.addEventListener('pointerdown', this._boundDragStart);
170
171
  this.addEventListener('click', this._boundClickCapture, true);
171
172
  this._running = !document.hidden;
173
+ this._lastActivity = performance.now();
172
174
  this._resumeFrames();
173
175
  }
174
176
 
@@ -176,6 +178,20 @@ const AgentRobotAvatar = (() => {
176
178
  unregisterConnectedFace(this);
177
179
  this.removeEventListener('pointerdown', this._boundDragStart);
178
180
  this.removeEventListener('click', this._boundClickCapture, true);
181
+ // Removal cancels work; reattachment starts idle with appearance settings intact.
182
+ this._resetToIdle(false);
183
+ this._blinkAnim = null;
184
+ this._blink = 1;
185
+ this._pointer.active = false;
186
+ this._pointer.influence = 0;
187
+ const drag = this._dragJelly;
188
+ if (drag.pointerId != null) {
189
+ try { this.releasePointerCapture(drag.pointerId); } catch (_) {}
190
+ }
191
+ drag.active = false;
192
+ drag.pointerId = null;
193
+ this._finishDragReturn(null);
194
+ this._suppressClick = false;
179
195
  this._running = false;
180
196
  cancelAnimationFrame(this._raf);
181
197
  this._raf = 0;
@@ -184,11 +200,18 @@ const AgentRobotAvatar = (() => {
184
200
  static get observedAttributes() { return ['color','size','auto-sleep']; }
185
201
  attributeChangedCallback(name, oldV, newV) {
186
202
  if (oldV === newV) return;
187
- if (name === 'color' && this._head) this._head.setAttribute('fill', newV || '#08090b');
203
+ if (name === 'color') this._applyColor(newV);
188
204
  if (name === 'size') this.style.setProperty('--face-size', `${Number(newV)||112}px`);
189
205
  if (name === 'auto-sleep') this._autoSleepMs = Number(newV) || 0;
190
206
  }
191
207
 
208
+ _applyColor(value) {
209
+ const color = value || '#08090b';
210
+ for (const part of [this._head, this._leftTop, this._rightTop, this._leftBottom, this._rightBottom, this._antennaDot]) {
211
+ if (part) part.setAttribute('fill', color);
212
+ }
213
+ }
214
+
192
215
  _renderShell() {
193
216
  this.shadowRoot.innerHTML = `
194
217
  <style>
@@ -246,10 +269,7 @@ const AgentRobotAvatar = (() => {
246
269
  this._rightTop = this.shadowRoot.getElementById('rightTop');
247
270
  this._leftBottom = this.shadowRoot.getElementById('leftBottom');
248
271
  this._rightBottom = this.shadowRoot.getElementById('rightBottom');
249
- const c = this.getAttribute('color') || '#08090b';
250
- this._head.setAttribute('fill', c);
251
- [this._leftTop,this._rightTop,this._leftBottom,this._rightBottom].forEach(el=>el.setAttribute('fill',c));
252
- this.style.setProperty('--face-size', `${Number(this.getAttribute('size'))||112}px`);
272
+ this._applyColor(this.getAttribute('color'));
253
273
  this._baseHeadPathD = this._headShape.getAttribute('d');
254
274
  this._baseHeadPoints = this._parseHeadPoints(this._baseHeadPathD);
255
275
  }
@@ -511,8 +531,14 @@ const AgentRobotAvatar = (() => {
511
531
  }
512
532
 
513
533
  reset() {
534
+ return this._resetToIdle();
535
+ }
536
+
537
+ _resetToIdle(notify = true) {
538
+ this._resetExtensionEffects?.();
514
539
  this._inputWanted = false;
515
540
  ++this._transitionToken;
541
+ this._cancelPendingWaits();
516
542
  this._boredRoutine = null;
517
543
  this._boredLookSpeed = null;
518
544
  this._sleeping = false;
@@ -520,7 +546,7 @@ const AgentRobotAvatar = (() => {
520
546
  this._eyeBob = null;
521
547
  this._angryEyeDrop = null;
522
548
  this._surpriseShake = null;
523
- this.setState('idle');
549
+ this.setState('idle', { notify, duration: notify ? 320 : 0 });
524
550
  return this;
525
551
  }
526
552
 
@@ -531,7 +557,7 @@ const AgentRobotAvatar = (() => {
531
557
  this._sleeping = name === 'sleep';
532
558
  if (name === 'idle' && !opts.keepGazeLock) this._releaseExpressionLock();
533
559
  this._startMorph(POSES[name], opts.duration ?? (name==='surprise'?170:320), opts.ease || EASE.outQuint);
534
- this.dispatchEvent(new CustomEvent('face-state', {detail:{state:name}}));
560
+ if (opts.notify !== false) this.dispatchEvent(new CustomEvent('face-state', {detail:{state:name}}));
535
561
  return this;
536
562
  }
537
563
 
@@ -559,6 +585,7 @@ const AgentRobotAvatar = (() => {
559
585
  } else {
560
586
  if (!(await this._prepareExpression({normalizePose:true, duration:180, pause:320}))) return;
561
587
  }
588
+ const token = this._transitionToken;
562
589
  this.setState('happy', {duration: fromDrag ? 180 : 220, keepGazeLock:true});
563
590
  this._eyeBob = {start: performance.now(), duration: 620, amp: 8};
564
591
  this._animateHead([
@@ -567,6 +594,7 @@ const AgentRobotAvatar = (() => {
567
594
  {transform:'translateY(0px)'}
568
595
  ], 760);
569
596
  await this._wait(980);
597
+ if (token !== this._transitionToken) return;
570
598
  this._eyeBob = null;
571
599
  if (this._state==='happy') await this._returnToIdle(520);
572
600
  }
@@ -575,6 +603,7 @@ const AgentRobotAvatar = (() => {
575
603
  this.noteActivity();
576
604
  this._inputWanted = false;
577
605
  if (!(await this._prepareExpression({normalizePose:true, duration:180, pause:320}))) return;
606
+ const token = this._transitionToken;
578
607
  this.setState('sad', {duration:320, keepGazeLock:true});
579
608
  this._eyeBob = {start: performance.now(), duration: 700, amp: 7};
580
609
  this._animateHead([
@@ -583,6 +612,7 @@ const AgentRobotAvatar = (() => {
583
612
  {transform:'translateY(0px)'}
584
613
  ], 820);
585
614
  await this._wait(1180);
615
+ if (token !== this._transitionToken) return;
586
616
  this._eyeBob = null;
587
617
  if (this._state==='sad') {
588
618
  await this._returnToIdle(560);
@@ -605,6 +635,7 @@ const AgentRobotAvatar = (() => {
605
635
  } else {
606
636
  if (!(await this._prepareExpression({normalizePose:true, duration:180, pause:320}))) return;
607
637
  }
638
+ const token = this._transitionToken;
608
639
  this.setState('angry', {duration: fromDrag ? 180 : 280, keepGazeLock:true});
609
640
  this._angryEyeDrop = {start: performance.now(), duration:680, amount:4.8};
610
641
  this._animateHead([
@@ -613,6 +644,7 @@ const AgentRobotAvatar = (() => {
613
644
  {transform:'translateY(0px)'}
614
645
  ], 820);
615
646
  await this._wait(980);
647
+ if (token !== this._transitionToken) return;
616
648
  this._angryEyeDrop = null;
617
649
  if (this._state==='angry') await this._returnToIdle(500);
618
650
  }
@@ -621,17 +653,19 @@ const AgentRobotAvatar = (() => {
621
653
  this.noteActivity();
622
654
  this._inputWanted = false;
623
655
  if (!(await this._prepareExpression({normalizePose:true, duration:180, pause:320}))) return;
656
+ const token = this._transitionToken;
624
657
  this._surpriseShake = null;
625
658
  const pre = {...POSES.idle, w:34, h:36, rx:18};
626
659
  this._startMorph(pre, 235, EASE.inOutCubic);
627
660
  await this._wait(235);
628
- if (this._state!=='idle') return;
661
+ if (token !== this._transitionToken || this._state!=='idle') return;
629
662
  this.setState('surprise', {duration:175, ease:EASE.outQuint, keepGazeLock:true});
630
663
  this._animateHead([{transform:'scale(1)'},{transform:'scale(1.035)'}], 175);
631
664
  await this._wait(175);
632
- if (this._state!=='surprise') return;
665
+ if (token !== this._transitionToken || this._state!=='surprise') return;
633
666
  this._surpriseShake = {start: performance.now(), duration:420};
634
667
  await this._wait(420);
668
+ if (token !== this._transitionToken) return;
635
669
  this._surpriseShake = null;
636
670
  if (this._state==='surprise') {
637
671
  this._animateHead([{transform:'scale(1.035)'},{transform:'scale(1)'}], 430);
@@ -643,6 +677,7 @@ const AgentRobotAvatar = (() => {
643
677
  this.noteActivity();
644
678
  this._inputWanted = false;
645
679
  if (!(await this._prepareExpression({normalizePose:true, duration:180, pause:320}))) return;
680
+ const token = this._transitionToken;
646
681
  this._eyeNod = {start: performance.now(), duration: 760};
647
682
  this._animateHead([
648
683
  {transform:'translateY(0px)'},
@@ -650,6 +685,7 @@ const AgentRobotAvatar = (() => {
650
685
  {transform:'translateY(0px)'}
651
686
  ], 920);
652
687
  await this._wait(860);
688
+ if (token !== this._transitionToken) return;
653
689
  this._eyeNod = null;
654
690
  this._releaseExpressionLock();
655
691
  }
@@ -657,21 +693,22 @@ const AgentRobotAvatar = (() => {
657
693
  async sleep() {
658
694
  if (this._sleeping) return;
659
695
  if (!(await this._prepareExpression({normalizePose:true, duration:160}))) return;
696
+ const token = this._transitionToken;
660
697
  this._blinkAnim = null;
661
698
  this._blink = 1;
662
699
  this._nextBlinkAt = performance.now() + 10000;
663
700
  this.setState('sleepy', {duration:940, ease:EASE.inOutCubic, keepGazeLock:true});
664
701
  await this._wait(980);
665
- if (this._state !== 'sleepy') return;
702
+ if (token !== this._transitionToken || this._state !== 'sleepy') return;
666
703
  const reopen = {...POSES.idle, topY:-22, bottomY:36};
667
704
  this._startMorph(reopen, 105, EASE.outQuint);
668
705
  await this._wait(120);
669
- if (this._state !== 'sleepy') return;
706
+ if (token !== this._transitionToken || this._state !== 'sleepy') return;
670
707
  this.setState('sleepy', {duration:820, ease:EASE.inOutCubic, keepGazeLock:true});
671
708
  await this._wait(870);
672
- if (this._state !== 'sleepy') return;
709
+ if (token !== this._transitionToken || this._state !== 'sleepy') return;
673
710
  await this._wait(120);
674
- if (this._state !== 'sleepy') return;
711
+ if (token !== this._transitionToken || this._state !== 'sleepy') return;
675
712
  this._animateHead([{transform:'translateY(0)'},{transform:'translateY(4px)'}],980);
676
713
  this._startMorph(POSES.sleep, 980, EASE.inOutCubic);
677
714
  this._state = 'sleep';
@@ -682,6 +719,7 @@ const AgentRobotAvatar = (() => {
682
719
 
683
720
  async wake() {
684
721
  if (!this._sleeping && this._state!=='sleep') return;
722
+ const token = ++this._transitionToken;
685
723
  this._expressionLock = true;
686
724
  this._sleeping = false;
687
725
  this._animateHead([
@@ -691,9 +729,12 @@ const AgentRobotAvatar = (() => {
691
729
  ], 500);
692
730
  this.setState('sleepy', {duration:240, keepGazeLock:true});
693
731
  await this._wait(260);
732
+ if (token !== this._transitionToken) return;
694
733
  this.setState('surprise', {duration:120, keepGazeLock:true});
695
734
  await this._wait(190);
735
+ if (token !== this._transitionToken) return;
696
736
  await this._returnToIdle(300);
737
+ if (token !== this._transitionToken) return;
697
738
  this._lastActivity = performance.now();
698
739
  }
699
740
 
@@ -715,18 +756,40 @@ const AgentRobotAvatar = (() => {
715
756
 
716
757
  _animateHead(frames, duration) {
717
758
  if (!this._headMotion.animate) return;
759
+ const token = this._transitionToken;
718
760
  const anim = this._headMotion.animate(frames, {duration, easing:'cubic-bezier(.16,1,.3,1)', fill:'forwards'});
719
- anim.onfinish = () => { this._headMotion.style.transform = frames[frames.length-1].transform || ''; };
761
+ anim.onfinish = () => {
762
+ if (token === this._transitionToken) {
763
+ this._headMotion.style.transform = frames[frames.length-1].transform || '';
764
+ }
765
+ };
720
766
  return anim;
721
767
  }
722
768
 
723
- _wait(ms) { return new Promise(r=>setTimeout(r,ms)); }
769
+ _wait(ms) {
770
+ return new Promise(resolve => {
771
+ const timer = setTimeout(() => {
772
+ this._pendingWaits.delete(timer);
773
+ resolve();
774
+ }, ms);
775
+ this._pendingWaits.set(timer, resolve);
776
+ });
777
+ }
778
+
779
+ _cancelPendingWaits() {
780
+ for (const [timer, resolve] of this._pendingWaits) {
781
+ clearTimeout(timer);
782
+ resolve();
783
+ }
784
+ this._pendingWaits.clear();
785
+ }
724
786
 
725
787
  async _returnToIdle(duration=320, ease=EASE.outQuint) {
788
+ const token = this._transitionToken;
726
789
  this.setState('idle', {duration, ease, keepGazeLock:true});
727
790
  this._headCenteringUntil = Math.max(this._headCenteringUntil || 0, performance.now() + duration);
728
791
  await this._wait(duration);
729
- if (this._state === 'idle') this._releaseExpressionLock();
792
+ if (token === this._transitionToken && this._state === 'idle') this._releaseExpressionLock();
730
793
  }
731
794
 
732
795
  async _prepareExpression({normalizePose=true, duration=180, pause=320}={}) {
@@ -848,7 +911,7 @@ const AgentRobotAvatar = (() => {
848
911
  }
849
912
 
850
913
  _autoSleep(now) {
851
- if (!this._autoSleepMs || this._sleeping || ['input','happy','sad','surprise'].includes(this._state)) return;
914
+ if (!this._autoSleepMs || this._sleeping || this._expressionLock || ['input','happy','sad','surprise'].includes(this._state)) return;
852
915
  if (now - this._lastActivity > this._autoSleepMs) this.sleep();
853
916
  }
854
917
 
@@ -58,9 +58,14 @@ proto.play = function(name) {
58
58
  return result;
59
59
  };
60
60
 
61
+ // Called dynamically by core reset and disconnect; custom-element lifecycle
62
+ // callbacks themselves were already registered before extensions loaded.
63
+ proto._resetExtensionEffects = function() {
64
+ for (const hook of resetHooks) hook.call(this);
65
+ };
66
+
61
67
  proto.reset = function() {
62
68
  this._resumeFrames?.();
63
- for (const hook of resetHooks) hook.call(this);
64
69
  return baseReset.call(this);
65
70
  };
66
71
 
@@ -13,6 +13,7 @@ function dispatchFailureState(instance, state) {
13
13
 
14
14
  function animateFailureHead(instance, frames, duration) {
15
15
  if (!instance._headMotion?.animate) return null;
16
+ const token = instance._transitionToken;
16
17
  instance._headMotion.style.transformBox = 'view-box';
17
18
  instance._headMotion.style.transformOrigin = '120px 120px';
18
19
  const anim = instance._headMotion.animate(frames, {
@@ -21,7 +22,9 @@ function animateFailureHead(instance, frames, duration) {
21
22
  fill: 'forwards',
22
23
  });
23
24
  anim.onfinish = () => {
24
- instance._headMotion.style.transform = frames[frames.length - 1].transform || '';
25
+ if (token === instance._transitionToken) {
26
+ instance._headMotion.style.transform = frames[frames.length - 1].transform || '';
27
+ }
25
28
  };
26
29
  return anim;
27
30
  }
@@ -95,6 +98,7 @@ proto.failure = async function() {
95
98
  { transform: 'translateY(0px)' },
96
99
  ], recover);
97
100
  if (this._state === 'sad') await this._returnToIdle(recover);
101
+ if (token !== this._transitionToken) return;
98
102
  this._failureFx = null;
99
103
  };
100
104
 
@@ -0,0 +1,40 @@
1
+ const DEFAULT_HEAD_HEIGHT_SCALE = 0.9;
2
+
3
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
4
+ const smooth01 = value => {
5
+ const t = clamp(value, 0, 1);
6
+ return t * t * (3 - 2 * t);
7
+ };
8
+
9
+ function flattenHeadPoints(points, heightScale = DEFAULT_HEAD_HEIGHT_SCALE) {
10
+ if (!points?.length) return [];
11
+ const source = points.map(point => ({ x: point.x, y: point.y }));
12
+ const minY = Math.min(...source.map(point => point.y));
13
+ const maxY = Math.max(...source.map(point => point.y));
14
+ const height = maxY - minY;
15
+ if (!(height > 0)) return source;
16
+
17
+ const scale = clamp(Number(heightScale), 0.1, 1);
18
+ const centerY = (minY + maxY) / 2;
19
+ const inset = height * (1 - scale) / 2;
20
+ const upperLockY = minY + height * 0.40;
21
+ const lowerLockY = maxY - height * 0.40;
22
+
23
+ return source.map(point => {
24
+ let shiftY = 0;
25
+ if (point.y <= upperLockY) {
26
+ shiftY = inset;
27
+ } else if (point.y < centerY) {
28
+ const t = (centerY - point.y) / Math.max(0.001, centerY - upperLockY);
29
+ shiftY = inset * smooth01(t);
30
+ } else if (point.y >= lowerLockY) {
31
+ shiftY = -inset;
32
+ } else if (point.y > centerY) {
33
+ const t = (point.y - centerY) / Math.max(0.001, lowerLockY - centerY);
34
+ shiftY = -inset * smooth01(t);
35
+ }
36
+ return { x: point.x, y: point.y + shiftY };
37
+ });
38
+ }
39
+
40
+ export { DEFAULT_HEAD_HEIGHT_SCALE, flattenHeadPoints };
@@ -1,47 +1,14 @@
1
1
  import AgentRobotAvatar from './agent-robot-avatar-extension-host.js';
2
+ import { flattenHeadPoints } from './agent-robot-avatar-geometry.js';
2
3
  import { VERSION } from './agent-robot-avatar-version.js';
3
4
 
4
5
  const proto = AgentRobotAvatar.prototype;
5
- const HEAD_HEIGHT_SCALE = 0.9;
6
6
  const DEFAULT_ROUNDNESS = 50;
7
7
 
8
8
  function clamp(value, min, max) {
9
9
  return Math.max(min, Math.min(max, value));
10
10
  }
11
11
 
12
- function smooth01(t) {
13
- t = clamp(t, 0, 1);
14
- return t * t * (3 - 2 * t);
15
- }
16
-
17
- function flattenPoints(points) {
18
- if (!points?.length) return [];
19
- const source = points.map(point => ({ x: point.x, y: point.y }));
20
- const minY = Math.min(...source.map(point => point.y));
21
- const maxY = Math.max(...source.map(point => point.y));
22
- const height = maxY - minY;
23
- const centerY = (minY + maxY) / 2;
24
- const inset = height * (1 - HEAD_HEIGHT_SCALE) / 2;
25
- const upperLockY = minY + height * 0.40;
26
- const lowerLockY = maxY - height * 0.40;
27
-
28
- return source.map(point => {
29
- let shiftY = 0;
30
- if (point.y <= upperLockY) {
31
- shiftY = inset;
32
- } else if (point.y < centerY) {
33
- const t = (centerY - point.y) / Math.max(0.001, centerY - upperLockY);
34
- shiftY = inset * smooth01(t);
35
- } else if (point.y >= lowerLockY) {
36
- shiftY = -inset;
37
- } else if (point.y > centerY) {
38
- const t = (point.y - centerY) / Math.max(0.001, lowerLockY - centerY);
39
- shiftY = -inset * smooth01(t);
40
- }
41
- return { x: point.x, y: point.y + shiftY };
42
- });
43
- }
44
-
45
12
  function ensureDefaultGeometry(instance) {
46
13
  if (!instance?._headShape || !instance._baseHeadPoints?.length) return false;
47
14
  if (instance._headRoundnessDefaultPoints?.length) return true;
@@ -49,7 +16,7 @@ function ensureDefaultGeometry(instance) {
49
16
  let points = instance._baseHeadPoints.map(point => ({ x: point.x, y: point.y }));
50
17
 
51
18
  if (!instance._headFlattenedR32) {
52
- points = flattenPoints(points);
19
+ points = flattenHeadPoints(points);
53
20
  instance._headFlattenedR32 = true;
54
21
  instance._baseHeadPoints = points.map(point => ({ ...point }));
55
22
  instance._baseHeadPathD = instance._pointsToPath(points);
@@ -31,46 +31,54 @@ const INSPECT_DEFAULTS = Object.freeze({
31
31
  open: 260,
32
32
  });
33
33
 
34
+ const INSPECT_BOUNDS = Object.freeze({
35
+ aperture: [6, 40],
36
+ scanOffset: [0, 24],
37
+ prepPause: [0, 2400],
38
+ close: [40, 2400],
39
+ holdClosed: [0, 2400],
40
+ down1: [40, 2400],
41
+ holdDown1: [0, 2400],
42
+ up1: [40, 2400],
43
+ holdUp1: [0, 2400],
44
+ center1: [40, 2400],
45
+ holdCenter: [0, 2400],
46
+ down2: [40, 2400],
47
+ holdDown2: [0, 2400],
48
+ up2: [40, 2400],
49
+ holdUp2: [0, 2400],
50
+ center2: [40, 2400],
51
+ holdAfter: [0, 2400],
52
+ open: [40, 2400],
53
+ });
54
+
34
55
  const runtimeWindow = typeof window !== 'undefined' ? window : null;
35
56
  const inspectConfig = (runtimeWindow?.AgentRobotAvatarInspectConfig && typeof runtimeWindow.AgentRobotAvatarInspectConfig === 'object')
36
57
  ? runtimeWindow.AgentRobotAvatarInspectConfig
37
58
  : {};
38
- for (const [key, value] of Object.entries(INSPECT_DEFAULTS)) {
39
- inspectConfig[key] = value;
59
+
60
+ function normalizeConfigValue(key, value) {
61
+ const fallback = INSPECT_DEFAULTS[key];
62
+ const numeric = Number(value);
63
+ if (!Number.isFinite(numeric)) return fallback;
64
+ const [min, max] = INSPECT_BOUNDS[key];
65
+ return Math.max(min, Math.min(max, numeric));
66
+ }
67
+
68
+ for (const key of Object.keys(INSPECT_DEFAULTS)) {
69
+ inspectConfig[key] = normalizeConfigValue(key, inspectConfig[key]);
40
70
  }
41
71
  if (runtimeWindow) {
42
72
  runtimeWindow.AgentRobotAvatarInspectConfig = inspectConfig;
43
73
  runtimeWindow.AgentRobotAvatarInspectDefaults = INSPECT_DEFAULTS;
44
74
  }
45
75
 
46
- function configNumber(key, fallback, min, max) {
47
- const value = Number(inspectConfig[key]);
48
- if (!Number.isFinite(value)) return fallback;
49
- return Math.max(min, Math.min(max, value));
50
- }
51
-
52
76
  function currentInspectConfig() {
53
- const duration = key => configNumber(key, INSPECT_DEFAULTS[key], 0, 2400);
54
- return {
55
- aperture: configNumber('aperture', INSPECT_DEFAULTS.aperture, 6, 40),
56
- scanOffset: configNumber('scanOffset', INSPECT_DEFAULTS.scanOffset, 0, 24),
57
- prepPause: duration('prepPause'),
58
- close: Math.max(40, duration('close')),
59
- holdClosed: duration('holdClosed'),
60
- down1: Math.max(40, duration('down1')),
61
- holdDown1: duration('holdDown1'),
62
- up1: Math.max(40, duration('up1')),
63
- holdUp1: duration('holdUp1'),
64
- center1: Math.max(40, duration('center1')),
65
- holdCenter: duration('holdCenter'),
66
- down2: Math.max(40, duration('down2')),
67
- holdDown2: duration('holdDown2'),
68
- up2: Math.max(40, duration('up2')),
69
- holdUp2: duration('holdUp2'),
70
- center2: Math.max(40, duration('center2')),
71
- holdAfter: duration('holdAfter'),
72
- open: Math.max(40, duration('open')),
73
- };
77
+ const config = {};
78
+ for (const key of Object.keys(INSPECT_DEFAULTS)) {
79
+ config[key] = normalizeConfigValue(key, inspectConfig[key]);
80
+ }
81
+ return config;
74
82
  }
75
83
 
76
84
  function dispatchInspectState(instance, state) {
@@ -122,6 +130,7 @@ function verticalShift(elapsed, phases, offset) {
122
130
  // Keep the R60 head motion unchanged: down, up past center, then back to center.
123
131
  function animateInspectHead(instance, phases) {
124
132
  if (!instance._headMotion?.animate) return;
133
+ const token = instance._transitionToken;
125
134
  const start = phases.down1Start;
126
135
  const end = phases.center1End;
127
136
  const duration = Math.max(1, end - start);
@@ -142,7 +151,9 @@ function animateInspectHead(instance, phases) {
142
151
  easing: 'linear',
143
152
  fill: 'forwards',
144
153
  });
145
- anim.onfinish = () => { instance._headMotion.style.transform = 'translateY(0px)'; };
154
+ anim.onfinish = () => {
155
+ if (token === instance._transitionToken) instance._headMotion.style.transform = 'translateY(0px)';
156
+ };
146
157
  }
147
158
 
148
159
  proto.inspect = async function() {