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
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
import AgentRobotAvatar from './agent-robot-avatar-core.js';
|
|
2
|
-
|
|
3
|
-
const proto = AgentRobotAvatar.prototype;
|
|
4
|
-
const basePlay = proto.play;
|
|
5
|
-
const baseReset = proto.reset;
|
|
6
|
-
const baseDraw = proto._draw;
|
|
7
|
-
const extensionNames = new Set();
|
|
8
|
-
const actionHandlers = new Map();
|
|
9
|
-
const beforePlayHooks = [];
|
|
10
|
-
const afterPlayHooks = [];
|
|
11
|
-
const resetHooks = [];
|
|
12
|
-
const drawHooks = [];
|
|
13
|
-
|
|
14
|
-
function registerAvatarExtension({
|
|
15
|
-
name,
|
|
16
|
-
actions = {},
|
|
17
|
-
beforePlay,
|
|
18
|
-
afterPlay,
|
|
19
|
-
reset,
|
|
20
|
-
draw,
|
|
21
|
-
}) {
|
|
22
|
-
if (!name || extensionNames.has(name)) {
|
|
23
|
-
throw new Error(`Agent Robot Avatar extension already registered: ${name || '(unnamed)'}`);
|
|
24
|
-
}
|
|
25
|
-
extensionNames.add(name);
|
|
26
|
-
|
|
27
|
-
for (const [action, handler] of Object.entries(actions)) {
|
|
28
|
-
if (actionHandlers.has(action)) {
|
|
29
|
-
throw new Error(`Agent Robot Avatar action already registered: ${action}`);
|
|
30
|
-
}
|
|
31
|
-
actionHandlers.set(action, handler);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (beforePlay) beforePlayHooks.push(beforePlay);
|
|
35
|
-
if (afterPlay) afterPlayHooks.push(afterPlay);
|
|
36
|
-
if (reset) resetHooks.push(reset);
|
|
37
|
-
if (draw) drawHooks.push(draw);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
proto.play = function(name) {
|
|
41
|
-
this._resumeFrames?.();
|
|
42
|
-
const action = String(name || '').trim().toLowerCase();
|
|
43
|
-
const context = {};
|
|
44
|
-
|
|
45
|
-
for (const hook of beforePlayHooks) hook.call(this, action, context);
|
|
46
|
-
|
|
47
|
-
let result;
|
|
48
|
-
try {
|
|
49
|
-
const handler = actionHandlers.get(action);
|
|
50
|
-
result = handler ? handler.call(this, action) : basePlay.call(this, action);
|
|
51
|
-
} catch (error) {
|
|
52
|
-
context.error = error;
|
|
53
|
-
for (const hook of afterPlayHooks) hook.call(this, action, undefined, context);
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
for (const hook of afterPlayHooks) hook.call(this, action, result, context);
|
|
58
|
-
return result;
|
|
59
|
-
};
|
|
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
|
-
|
|
67
|
-
proto.reset = function() {
|
|
68
|
-
this._resumeFrames?.();
|
|
69
|
-
return baseReset.call(this);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
proto._draw = function(now) {
|
|
73
|
-
baseDraw.call(this, now);
|
|
74
|
-
for (const hook of drawHooks) hook.call(this, now);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export { AgentRobotAvatar, registerAvatarExtension };
|
|
78
|
-
export default AgentRobotAvatar;
|
|
1
|
+
import AgentRobotAvatar from './agent-robot-avatar-core.js';
|
|
2
|
+
|
|
3
|
+
const proto = AgentRobotAvatar.prototype;
|
|
4
|
+
const basePlay = proto.play;
|
|
5
|
+
const baseReset = proto.reset;
|
|
6
|
+
const baseDraw = proto._draw;
|
|
7
|
+
const extensionNames = new Set();
|
|
8
|
+
const actionHandlers = new Map();
|
|
9
|
+
const beforePlayHooks = [];
|
|
10
|
+
const afterPlayHooks = [];
|
|
11
|
+
const resetHooks = [];
|
|
12
|
+
const drawHooks = [];
|
|
13
|
+
|
|
14
|
+
function registerAvatarExtension({
|
|
15
|
+
name,
|
|
16
|
+
actions = {},
|
|
17
|
+
beforePlay,
|
|
18
|
+
afterPlay,
|
|
19
|
+
reset,
|
|
20
|
+
draw,
|
|
21
|
+
}) {
|
|
22
|
+
if (!name || extensionNames.has(name)) {
|
|
23
|
+
throw new Error(`Agent Robot Avatar extension already registered: ${name || '(unnamed)'}`);
|
|
24
|
+
}
|
|
25
|
+
extensionNames.add(name);
|
|
26
|
+
|
|
27
|
+
for (const [action, handler] of Object.entries(actions)) {
|
|
28
|
+
if (actionHandlers.has(action)) {
|
|
29
|
+
throw new Error(`Agent Robot Avatar action already registered: ${action}`);
|
|
30
|
+
}
|
|
31
|
+
actionHandlers.set(action, handler);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (beforePlay) beforePlayHooks.push(beforePlay);
|
|
35
|
+
if (afterPlay) afterPlayHooks.push(afterPlay);
|
|
36
|
+
if (reset) resetHooks.push(reset);
|
|
37
|
+
if (draw) drawHooks.push(draw);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
proto.play = function(name) {
|
|
41
|
+
this._resumeFrames?.();
|
|
42
|
+
const action = String(name || '').trim().toLowerCase();
|
|
43
|
+
const context = {};
|
|
44
|
+
|
|
45
|
+
for (const hook of beforePlayHooks) hook.call(this, action, context);
|
|
46
|
+
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
const handler = actionHandlers.get(action);
|
|
50
|
+
result = handler ? handler.call(this, action) : basePlay.call(this, action);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
context.error = error;
|
|
53
|
+
for (const hook of afterPlayHooks) hook.call(this, action, undefined, context);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const hook of afterPlayHooks) hook.call(this, action, result, context);
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
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
|
+
|
|
67
|
+
proto.reset = function() {
|
|
68
|
+
this._resumeFrames?.();
|
|
69
|
+
return baseReset.call(this);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
proto._draw = function(now) {
|
|
73
|
+
baseDraw.call(this, now);
|
|
74
|
+
for (const hook of drawHooks) hook.call(this, now);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export { AgentRobotAvatar, registerAvatarExtension };
|
|
78
|
+
export default AgentRobotAvatar;
|
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
|
|
2
|
-
|
|
3
|
-
const proto = AgentRobotAvatar.prototype;
|
|
4
|
-
|
|
5
|
-
const FAILURE_HEAD_DROP = 5.5;
|
|
6
|
-
const FAILURE_EYE_DROP = 4.8;
|
|
7
|
-
|
|
8
|
-
function dispatchFailureState(instance, state) {
|
|
9
|
-
instance.dispatchEvent(new CustomEvent('face-state', {
|
|
10
|
-
detail: { state }
|
|
11
|
-
}));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function animateFailureHead(instance, frames, duration) {
|
|
15
|
-
if (!instance._headMotion?.animate) return null;
|
|
16
|
-
const token = instance._transitionToken;
|
|
17
|
-
instance._headMotion.style.transformBox = 'view-box';
|
|
18
|
-
instance._headMotion.style.transformOrigin = '120px 120px';
|
|
19
|
-
const anim = instance._headMotion.animate(frames, {
|
|
20
|
-
duration,
|
|
21
|
-
easing: 'cubic-bezier(.32,0,.24,1)',
|
|
22
|
-
fill: 'forwards',
|
|
23
|
-
});
|
|
24
|
-
anim.onfinish = () => {
|
|
25
|
-
if (token === instance._transitionToken) {
|
|
26
|
-
instance._headMotion.style.transform = frames[frames.length - 1].transform || '';
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
return anim;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function smooth01(t) {
|
|
33
|
-
t = Math.max(0, Math.min(1, t));
|
|
34
|
-
return t * t * (3 - 2 * t);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function failureEyeDrop(fx, now) {
|
|
38
|
-
const elapsed = Math.max(0, now - fx.start);
|
|
39
|
-
if (elapsed < fx.headDown) {
|
|
40
|
-
return FAILURE_EYE_DROP * smooth01(elapsed / Math.max(1, fx.headDown));
|
|
41
|
-
}
|
|
42
|
-
if (elapsed < fx.headDown + fx.hold) return FAILURE_EYE_DROP;
|
|
43
|
-
const recoverElapsed = elapsed - fx.headDown - fx.hold;
|
|
44
|
-
if (recoverElapsed < fx.recover) {
|
|
45
|
-
return FAILURE_EYE_DROP * (1 - smooth01(recoverElapsed / Math.max(1, fx.recover)));
|
|
46
|
-
}
|
|
47
|
-
return 0;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function addEyeDrop(eye, amount) {
|
|
51
|
-
if (!eye || Math.abs(amount) < 0.001) return;
|
|
52
|
-
const current = eye.getAttribute('transform') || '';
|
|
53
|
-
eye.setAttribute('transform', `${current} translate(0 ${amount.toFixed(2)})`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
proto.failure = async function() {
|
|
57
|
-
this.noteActivity();
|
|
58
|
-
this._inputWanted = false;
|
|
59
|
-
this._failureFx = null;
|
|
60
|
-
|
|
61
|
-
// Clear waiting/inspect/flash or any other active extension before entering failure.
|
|
62
|
-
this.reset();
|
|
63
|
-
if (!(await this._prepareExpression({ normalizePose: true, duration: 180, pause: 260 }))) return;
|
|
64
|
-
const token = this._transitionToken;
|
|
65
|
-
|
|
66
|
-
// First finish the sad/failure eye deformation completely.
|
|
67
|
-
const eyeIn = 320;
|
|
68
|
-
this.setState('sad', { duration: eyeIn, keepGazeLock: true });
|
|
69
|
-
dispatchFailureState(this, 'failure');
|
|
70
|
-
await this._wait(eyeIn);
|
|
71
|
-
if (token !== this._transitionToken || this._state !== 'sad') return;
|
|
72
|
-
|
|
73
|
-
// Only after the eyes have settled, lower the head and press the eyes down inside it together.
|
|
74
|
-
const headDown = 720;
|
|
75
|
-
const loweredHold = 720;
|
|
76
|
-
const recover = 520;
|
|
77
|
-
this._failureFx = {
|
|
78
|
-
start: performance.now(),
|
|
79
|
-
headDown,
|
|
80
|
-
hold: loweredHold,
|
|
81
|
-
recover,
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
animateFailureHead(this, [
|
|
85
|
-
{ transform: 'translateY(0px)' },
|
|
86
|
-
{ transform: `translateY(${FAILURE_HEAD_DROP}px)` },
|
|
87
|
-
], headDown);
|
|
88
|
-
await this._wait(headDown);
|
|
89
|
-
if (token !== this._transitionToken) return;
|
|
90
|
-
|
|
91
|
-
// Hold the lowered, sad pose longer before recovering.
|
|
92
|
-
await this._wait(loweredHold);
|
|
93
|
-
if (token !== this._transitionToken) return;
|
|
94
|
-
|
|
95
|
-
// Head, eyes and sad expression recover together.
|
|
96
|
-
animateFailureHead(this, [
|
|
97
|
-
{ transform: `translateY(${FAILURE_HEAD_DROP}px)` },
|
|
98
|
-
{ transform: 'translateY(0px)' },
|
|
99
|
-
], recover);
|
|
100
|
-
if (this._state === 'sad') await this._returnToIdle(recover);
|
|
101
|
-
if (token !== this._transitionToken) return;
|
|
102
|
-
this._failureFx = null;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
function drawFailure(now) {
|
|
106
|
-
const fx = this._failureFx;
|
|
107
|
-
if (!fx) return;
|
|
108
|
-
const drop = failureEyeDrop(fx, now);
|
|
109
|
-
addEyeDrop(this._leftEye, drop);
|
|
110
|
-
addEyeDrop(this._rightEye, drop);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
registerAvatarExtension({
|
|
114
|
-
name: 'failure',
|
|
115
|
-
actions: {
|
|
116
|
-
failure() { return this.failure(); },
|
|
117
|
-
failed() { return this.failure(); },
|
|
118
|
-
fail() { return this.failure(); },
|
|
119
|
-
},
|
|
120
|
-
beforePlay(action) {
|
|
121
|
-
if (action === 'failure' || action === 'failed' || action === 'fail') return;
|
|
122
|
-
this._failureFx = null;
|
|
123
|
-
},
|
|
124
|
-
reset() {
|
|
125
|
-
this._failureFx = null;
|
|
126
|
-
},
|
|
127
|
-
draw: drawFailure,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
export { AgentRobotAvatar };
|
|
131
|
-
export default AgentRobotAvatar;
|
|
1
|
+
import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
|
|
2
|
+
|
|
3
|
+
const proto = AgentRobotAvatar.prototype;
|
|
4
|
+
|
|
5
|
+
const FAILURE_HEAD_DROP = 5.5;
|
|
6
|
+
const FAILURE_EYE_DROP = 4.8;
|
|
7
|
+
|
|
8
|
+
function dispatchFailureState(instance, state) {
|
|
9
|
+
instance.dispatchEvent(new CustomEvent('face-state', {
|
|
10
|
+
detail: { state }
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function animateFailureHead(instance, frames, duration) {
|
|
15
|
+
if (!instance._headMotion?.animate) return null;
|
|
16
|
+
const token = instance._transitionToken;
|
|
17
|
+
instance._headMotion.style.transformBox = 'view-box';
|
|
18
|
+
instance._headMotion.style.transformOrigin = '120px 120px';
|
|
19
|
+
const anim = instance._headMotion.animate(frames, {
|
|
20
|
+
duration,
|
|
21
|
+
easing: 'cubic-bezier(.32,0,.24,1)',
|
|
22
|
+
fill: 'forwards',
|
|
23
|
+
});
|
|
24
|
+
anim.onfinish = () => {
|
|
25
|
+
if (token === instance._transitionToken) {
|
|
26
|
+
instance._headMotion.style.transform = frames[frames.length - 1].transform || '';
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
return anim;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function smooth01(t) {
|
|
33
|
+
t = Math.max(0, Math.min(1, t));
|
|
34
|
+
return t * t * (3 - 2 * t);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function failureEyeDrop(fx, now) {
|
|
38
|
+
const elapsed = Math.max(0, now - fx.start);
|
|
39
|
+
if (elapsed < fx.headDown) {
|
|
40
|
+
return FAILURE_EYE_DROP * smooth01(elapsed / Math.max(1, fx.headDown));
|
|
41
|
+
}
|
|
42
|
+
if (elapsed < fx.headDown + fx.hold) return FAILURE_EYE_DROP;
|
|
43
|
+
const recoverElapsed = elapsed - fx.headDown - fx.hold;
|
|
44
|
+
if (recoverElapsed < fx.recover) {
|
|
45
|
+
return FAILURE_EYE_DROP * (1 - smooth01(recoverElapsed / Math.max(1, fx.recover)));
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function addEyeDrop(eye, amount) {
|
|
51
|
+
if (!eye || Math.abs(amount) < 0.001) return;
|
|
52
|
+
const current = eye.getAttribute('transform') || '';
|
|
53
|
+
eye.setAttribute('transform', `${current} translate(0 ${amount.toFixed(2)})`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
proto.failure = async function() {
|
|
57
|
+
this.noteActivity();
|
|
58
|
+
this._inputWanted = false;
|
|
59
|
+
this._failureFx = null;
|
|
60
|
+
|
|
61
|
+
// Clear waiting/inspect/flash or any other active extension before entering failure.
|
|
62
|
+
this.reset();
|
|
63
|
+
if (!(await this._prepareExpression({ normalizePose: true, duration: 180, pause: 260 }))) return;
|
|
64
|
+
const token = this._transitionToken;
|
|
65
|
+
|
|
66
|
+
// First finish the sad/failure eye deformation completely.
|
|
67
|
+
const eyeIn = 320;
|
|
68
|
+
this.setState('sad', { duration: eyeIn, keepGazeLock: true });
|
|
69
|
+
dispatchFailureState(this, 'failure');
|
|
70
|
+
await this._wait(eyeIn);
|
|
71
|
+
if (token !== this._transitionToken || this._state !== 'sad') return;
|
|
72
|
+
|
|
73
|
+
// Only after the eyes have settled, lower the head and press the eyes down inside it together.
|
|
74
|
+
const headDown = 720;
|
|
75
|
+
const loweredHold = 720;
|
|
76
|
+
const recover = 520;
|
|
77
|
+
this._failureFx = {
|
|
78
|
+
start: performance.now(),
|
|
79
|
+
headDown,
|
|
80
|
+
hold: loweredHold,
|
|
81
|
+
recover,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
animateFailureHead(this, [
|
|
85
|
+
{ transform: 'translateY(0px)' },
|
|
86
|
+
{ transform: `translateY(${FAILURE_HEAD_DROP}px)` },
|
|
87
|
+
], headDown);
|
|
88
|
+
await this._wait(headDown);
|
|
89
|
+
if (token !== this._transitionToken) return;
|
|
90
|
+
|
|
91
|
+
// Hold the lowered, sad pose longer before recovering.
|
|
92
|
+
await this._wait(loweredHold);
|
|
93
|
+
if (token !== this._transitionToken) return;
|
|
94
|
+
|
|
95
|
+
// Head, eyes and sad expression recover together.
|
|
96
|
+
animateFailureHead(this, [
|
|
97
|
+
{ transform: `translateY(${FAILURE_HEAD_DROP}px)` },
|
|
98
|
+
{ transform: 'translateY(0px)' },
|
|
99
|
+
], recover);
|
|
100
|
+
if (this._state === 'sad') await this._returnToIdle(recover);
|
|
101
|
+
if (token !== this._transitionToken) return;
|
|
102
|
+
this._failureFx = null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function drawFailure(now) {
|
|
106
|
+
const fx = this._failureFx;
|
|
107
|
+
if (!fx) return;
|
|
108
|
+
const drop = failureEyeDrop(fx, now);
|
|
109
|
+
addEyeDrop(this._leftEye, drop);
|
|
110
|
+
addEyeDrop(this._rightEye, drop);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
registerAvatarExtension({
|
|
114
|
+
name: 'failure',
|
|
115
|
+
actions: {
|
|
116
|
+
failure() { return this.failure(); },
|
|
117
|
+
failed() { return this.failure(); },
|
|
118
|
+
fail() { return this.failure(); },
|
|
119
|
+
},
|
|
120
|
+
beforePlay(action) {
|
|
121
|
+
if (action === 'failure' || action === 'failed' || action === 'fail') return;
|
|
122
|
+
this._failureFx = null;
|
|
123
|
+
},
|
|
124
|
+
reset() {
|
|
125
|
+
this._failureFx = null;
|
|
126
|
+
},
|
|
127
|
+
draw: drawFailure,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export { AgentRobotAvatar };
|
|
131
|
+
export default AgentRobotAvatar;
|
|
@@ -1,40 +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
|
+
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 };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
|
|
2
|
+
|
|
3
|
+
const proto=AgentRobotAvatar.prototype;
|
|
4
|
+
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
|
|
5
|
+
const disabled=(face,key)=>face.getAttribute(key)==='false';
|
|
6
|
+
const emit=(face,state)=>face.dispatchEvent(new CustomEvent('face-state',{detail:{state}}));
|
|
7
|
+
const gazeTarget=(box,event)=>({
|
|
8
|
+
x:clamp((event.clientX-box.left-box.width/2)*.42,-48,48),
|
|
9
|
+
y:clamp((event.clientY-box.top-box.height/2)*.38,-34,34),
|
|
10
|
+
});
|
|
11
|
+
const SQUEEZE_HOLD_SECONDS=.14;
|
|
12
|
+
|
|
13
|
+
function restore(face) {
|
|
14
|
+
face._headMotion.style.transform='';face._headMotion.removeAttribute('transform');
|
|
15
|
+
face._dragMotion.style.transform='';face._dragMotion.style.transformOrigin='';
|
|
16
|
+
face._headShape.setAttribute('d',face._baseHeadPathD);
|
|
17
|
+
face._antennaDot?.removeAttribute('transform');
|
|
18
|
+
if(face._antennaSpring)face._antennaSpring.initialized=false;
|
|
19
|
+
face._releaseExpressionLock();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
proto._cancelGesture=function(silent=false) {
|
|
23
|
+
const fx=this._gestureFx;if(!fx)return;
|
|
24
|
+
this._gestureFx=null;
|
|
25
|
+
try{if(fx.pointerId!=null&&this.hasPointerCapture(fx.pointerId))this.releasePointerCapture(fx.pointerId);}catch(_){ }
|
|
26
|
+
if(!fx.committed)return;
|
|
27
|
+
restore(this);this._endGestureAction?.('cancel',false,silent);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function commit(face,s,look=null) {
|
|
31
|
+
if(s.committed||face._gestureFx!==s)return false;
|
|
32
|
+
face._gestureFx=null;
|
|
33
|
+
face.reset();
|
|
34
|
+
face._gestureFx=s;s.committed=true;face.noteActivity(false);face._expressionLock=true;
|
|
35
|
+
if(look){face._look.x=look.x;face._look.y=look.y;}
|
|
36
|
+
face._beginGestureAction?.(s.kind);emit(face,s.kind);face._resumeFrames();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
proto.setPressSqueeze=function(enabled=true){
|
|
41
|
+
this.setAttribute('press-squeeze',String(Boolean(enabled)));
|
|
42
|
+
if(!enabled && this._gestureFx?.kind==='squeeze')this._cancelGesture();
|
|
43
|
+
return this;
|
|
44
|
+
};
|
|
45
|
+
// Antenna blinking was removed in 0.6. The method stays so existing calls keep working.
|
|
46
|
+
let warnedAntennaFlash=false;
|
|
47
|
+
proto.setAntennaFlash=function(){
|
|
48
|
+
if(!warnedAntennaFlash){
|
|
49
|
+
warnedAntennaFlash=true;
|
|
50
|
+
console.warn('agent-robot-avatar: setAntennaFlash() was removed in 0.6 and does nothing. It will be deleted in 1.0.');
|
|
51
|
+
}
|
|
52
|
+
return this;
|
|
53
|
+
};
|
|
54
|
+
proto.setAntennaDrag=function(enabled=true){
|
|
55
|
+
this.setAttribute('antenna-drag',String(Boolean(enabled)));
|
|
56
|
+
if(!enabled && this._gestureFx?.kind==='antenna-drag')this._cancelGesture();
|
|
57
|
+
return this;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
proto._startGesture=function(event) {
|
|
61
|
+
if(event.button!==0||event.isPrimary===false||this._gestureFx?.held)return false;
|
|
62
|
+
const box=this.getBoundingClientRect(),dot=this._antennaDot;
|
|
63
|
+
const x=(event.clientX-box.left)/box.width-.5,y=(event.clientY-box.top)/box.height-.5;
|
|
64
|
+
const r=dot?.getBoundingClientRect(),radius=r?Math.max(10,Math.max(r.width,r.height)/2+3):0;
|
|
65
|
+
const onAntenna=r && Number(dot.getAttribute('opacity')??1)>.01 &&
|
|
66
|
+
Math.hypot(event.clientX-r.left-r.width/2,event.clientY-r.top-r.height/2)<=radius;
|
|
67
|
+
const kind=onAntenna&&!disabled(this,'antenna-drag')?'antenna-drag':
|
|
68
|
+
!onAntenna&&event.pointerType!=='touch'&&!disabled(this,'press-squeeze')&&x*x+y*y<=.32*.32?'squeeze':null;
|
|
69
|
+
if(!kind)return false;
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
const home={x:Number(dot?.getAttribute('cx')??120),y:Number(dot?.getAttribute('cy')??12)};
|
|
72
|
+
const gaze=kind==='antenna-drag'?gazeTarget(box,event):null;
|
|
73
|
+
const currentLook=gaze?{...this._look}:null;
|
|
74
|
+
this._gestureFx={kind,held:true,pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,
|
|
75
|
+
pointerType:event.pointerType,size:Math.max(1,box.width),threshold:clamp(box.width*.4,24,46),home,
|
|
76
|
+
start:performance.now(),last:performance.now(),age:0,releaseAge:0,maxDist:0,react:false,committed:false,
|
|
77
|
+
gazeX:gaze?.x??0,gazeY:gaze?.y??0,
|
|
78
|
+
q:0,v:0,pressure:0,apex:0,releaseDepth:1,x:0,y:0,vx:0,vy:0,hx:0,hy:0,hvx:0,hvy:0,tx:0,ty:0};
|
|
79
|
+
try{this.setPointerCapture(event.pointerId);}catch(_){ }
|
|
80
|
+
if(kind==='antenna-drag')commit(this,this._gestureFx,currentLook);
|
|
81
|
+
this._resumeFrames();return true;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
proto._moveGesture=function(event) {
|
|
85
|
+
const s=this._gestureFx;if(!s?.held||s.pointerId!==event.pointerId)return;
|
|
86
|
+
if(s.kind==='antenna-drag'){
|
|
87
|
+
const gaze=gazeTarget(this.getBoundingClientRect(),event);s.gazeX=gaze.x;s.gazeY=gaze.y;
|
|
88
|
+
}
|
|
89
|
+
const dx=event.clientX-s.startX,dy=event.clientY-s.startY,dist=Math.hypot(dx,dy);
|
|
90
|
+
s.maxDist=Math.max(s.maxDist,dist);
|
|
91
|
+
if(s.kind==='squeeze'&&dist>4){
|
|
92
|
+
this._cancelGesture(true);this._startHeadDragFromGesture?.(event,s);return;
|
|
93
|
+
}
|
|
94
|
+
if(dist>2.5)this._suppressClick=true;
|
|
95
|
+
const gain=dist>s.threshold*.6 ? .24 : .19,scale=240/s.size;
|
|
96
|
+
s.tx=clamp(dx*scale*gain,-25,25);s.ty=clamp(dy*scale*gain,-25,25);
|
|
97
|
+
this._runtimeVisualDirty=true;this._resumeFrames();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
proto._endGesture=function(event) {
|
|
101
|
+
const s=this._gestureFx;if(!s?.held||s.pointerId!==event.pointerId)return;
|
|
102
|
+
if(event.type==='pointercancel'){this._cancelGesture();return;}
|
|
103
|
+
if(!s.committed){this._cancelGesture(true);return;}
|
|
104
|
+
s.held=false;s.releaseAge=0;s.react=s.kind==='antenna-drag'&&s.maxDist>2.5;s.releaseDepth=Math.min(1,Math.abs(s.q));
|
|
105
|
+
const id=s.pointerId;s.pointerId=null;
|
|
106
|
+
try{if(this.hasPointerCapture(id))this.releasePointerCapture(id);}catch(_){ }
|
|
107
|
+
if(s.kind==='squeeze'&&s.age>.15)this._suppressClick=true;
|
|
108
|
+
this._runtimeVisualDirty=true;this._resumeFrames();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function finish(face,s) {
|
|
112
|
+
face._gestureFx=null;restore(face);emit(face,'idle');
|
|
113
|
+
face._endGestureAction?.('end',s.react);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function drawSqueeze(face,s,dt,reduced) {
|
|
117
|
+
if(reduced){s.q=s.held?1:0;s.v=0;}
|
|
118
|
+
else {
|
|
119
|
+
const omega=6/.57,k=s.held?omega*omega:190,d=s.held?2*omega:9,oldV=s.v;
|
|
120
|
+
for(let remaining=dt;remaining>0;){const h=Math.min(1/240,remaining);remaining-=h;s.v+=((s.held?1:0)-s.q)*k*h-s.v*d*h;s.q+=s.v*h;}
|
|
121
|
+
if(!s.held&&!s.apex&&oldV<0&&s.v>=0&&s.q<0)s.apex=s.age;
|
|
122
|
+
}
|
|
123
|
+
const pressure=s.held?clamp((s.q-.94)/.06,0,1):s.pressure*Math.exp(-s.releaseAge*20);
|
|
124
|
+
if(s.held)s.pressure=pressure;
|
|
125
|
+
const phase=s.age*2*Math.PI,jitter=reduced?0:2*pressure;
|
|
126
|
+
const ripple=s.apex ? .012*Math.exp(-(s.age-s.apex)*12)*Math.sin((s.age-s.apex)*72)*s.releaseDepth : 0;
|
|
127
|
+
const jelly=jitter*.005*Math.sin(phase*11)+ripple,scale=1-.29*s.q;
|
|
128
|
+
face._headFollow.setAttribute('transform','');
|
|
129
|
+
face._headMotion.style.transform=`translate(${jitter*(.7*Math.sin(phase*13)+.3*Math.sin(phase*19))}px,${jitter*.45*Math.sin(phase*17)}px) scale(${scale+jelly},${scale-jelly})`;
|
|
130
|
+
for(const [side,home,delta] of [['left',86,24],['right',154,-24]]) {
|
|
131
|
+
face['_'+side+'Eye'].setAttribute('transform',`translate(${home+delta*s.q} 126)`);
|
|
132
|
+
const eye=face['_'+side+'Base'];eye.setAttribute('rx',Math.max(0,54-31*s.q)/2);eye.setAttribute('ry',Math.max(.1,58-20*s.q)/2);eye.setAttribute('opacity','1');
|
|
133
|
+
face['_'+side+'InputBase'].setAttribute('opacity','0');
|
|
134
|
+
face['_'+side+'Top'].setAttribute('y','-126');face['_'+side+'Top'].setAttribute('height','90');
|
|
135
|
+
face['_'+side+'Bottom'].setAttribute('y','36');
|
|
136
|
+
}
|
|
137
|
+
if(face._antennaDot){face._antennaDot.setAttribute('cx',s.home.x+jitter*.6*Math.sin(phase*11-.5));face._antennaDot.setAttribute('cy',s.home.y+(1-scale+jelly)*78-s.v*.5);}
|
|
138
|
+
return !s.held&&(reduced||(s.releaseAge>.35&&Math.abs(s.q)<.0015&&Math.abs(s.v)<.018));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function drawAntennaDrag(face,s,dt,reduced) {
|
|
142
|
+
const settle=clamp((s.releaseAge-.45)/.35,0,1);
|
|
143
|
+
if(reduced){s.x=s.held?s.tx:0;s.y=s.held?s.ty:0;s.hx=s.x*.45;s.hy=s.y*.45;}
|
|
144
|
+
else for(let remaining=dt;remaining>0;){
|
|
145
|
+
const h=Math.min(1/240,remaining);remaining-=h;
|
|
146
|
+
if(s.held){const b=1-Math.exp(-45*h);s.x+=(s.tx-s.x)*b;s.y+=(s.ty-s.y)*b;s.vx=s.vy=0;}
|
|
147
|
+
else {const d=10+14*settle;s.vx+=(-460*s.x-d*s.vx)*h;s.vy+=(-460*s.y-d*s.vy)*h;s.x+=s.vx*h;s.y+=s.vy*h;}
|
|
148
|
+
const k=s.held?118:330,d=s.held?7.1:10+12*settle;
|
|
149
|
+
s.hvx+=(k*((s.held?s.x*.45:0)-s.hx)-d*s.hvx)*h;s.hvy+=(k*((s.held?s.y*.45:0)-s.hy)-d*s.hvy)*h;
|
|
150
|
+
s.hx+=s.hvx*h;s.hy+=s.hvy*h;
|
|
151
|
+
}
|
|
152
|
+
const pullX=clamp(s.hx,-25,25),pullY=clamp(s.hy,-25,25),radius=(50+Math.hypot(pullX,pullY)*.8)*.92;
|
|
153
|
+
const points=face._baseHeadPoints.map(p=>{const w=Math.exp(-((p.x-s.home.x)**2+(p.y-clamp(s.home.y,20,220))**2)/(2*radius*radius));return{x:p.x+pullX*w,y:p.y+pullY*w};});
|
|
154
|
+
face._headShape.setAttribute('d',face._pointsToPath(points));face._headFollow.setAttribute('transform','');
|
|
155
|
+
face._headMotion.style.transform=`translate(${s.hx*.12}px,${s.hy*.12}px) rotate(${s.hx*.08}deg)`;
|
|
156
|
+
const dot=face._antennaDot;
|
|
157
|
+
if(dot){const x=s.home.x+s.x,y=s.home.y+s.y,stretch=clamp(s.vy*.0004,-.06,.06);dot.setAttribute('cx',x);dot.setAttribute('cy',y);dot.setAttribute('transform',`translate(${x} ${y}) scale(${1+stretch} ${1-stretch}) translate(${-x} ${-y})`);}
|
|
158
|
+
return !s.held&&(reduced||(Math.max(Math.abs(s.x),Math.abs(s.y),Math.abs(s.hx),Math.abs(s.hy))<.025&&Math.max(Math.abs(s.vx),Math.abs(s.vy),Math.abs(s.hvx),Math.abs(s.hvy))<.06));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
registerAvatarExtension({name:'gestures',reset(){this._cancelGesture(this._runtimeResetNotify===false);},draw(now){
|
|
162
|
+
const s=this._gestureFx;if(!s)return;
|
|
163
|
+
const dt=Math.min(.034,Math.max(0,(now-s.last)/1000));s.last=now;s.age+=dt;if(!s.held)s.releaseAge+=dt;
|
|
164
|
+
if(s.kind==='squeeze'&&!s.committed){
|
|
165
|
+
if(s.age<SQUEEZE_HOLD_SECONDS)return;
|
|
166
|
+
if(!commit(this,s))return;
|
|
167
|
+
}
|
|
168
|
+
const done=s.kind==='squeeze'?drawSqueeze(this,s,dt,this._isReducedMotion?.()):drawAntennaDrag(this,s,dt,this._isReducedMotion?.());
|
|
169
|
+
if(done)finish(this,s);
|
|
170
|
+
}});
|