agent-robot-avatar 0.5.2 → 0.7.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 +56 -7
- package/README.md +93 -16
- package/agent-robot-avatar-lite.js +44 -0
- package/agent-robot-avatar.js +2 -2
- package/index.d.ts +94 -17
- package/lite.d.ts +5 -0
- package/package.json +12 -5
- package/src/agent-robot-avatar-actions.js +17 -14
- package/src/agent-robot-avatar-core.js +5 -2
- package/src/agent-robot-avatar-gestures.js +9 -0
- package/src/agent-robot-avatar-runtime.js +58 -12
- package/src/agent-robot-avatar-skin-patterns.js +234 -0
- package/src/agent-robot-avatar-skins.js +256 -0
- package/src/agent-robot-avatar-version.js +1 -1
- package/src/agent-robot-avatar-waiting.js +30 -6
- package/src/skins/catalog.js +218 -0
- package/src/skins/collection-materials.js +118 -0
- package/src/skins/eyelids.js +32 -0
- package/src/skins/glaze-variants.js +29 -0
- package/src/skins/layered-texture.js +162 -0
- package/src/skins/material-data.js +111 -0
- package/src/skins/material-library.js +302 -0
- package/src/skins/metal-tint.js +11 -0
- package/src/skins/more-materials.js +69 -0
- package/src/skins/native.js +754 -0
- package/src/skins/paints.js +3 -0
- package/src/skins/revised-materials.js +44 -0
- package/src/skins/second-pass-materials.js +71 -0
- package/src/skins/soft-materials.js +111 -0
- package/src/skins/sports-materials.js +38 -0
- package/src/skins/wood-materials.js +68 -0
- package/assets/support/alipay.png +0 -0
- package/assets/support/wechat-pay.png +0 -0
- package/docs/README.de.md +0 -186
- package/docs/README.es.md +0 -186
- package/docs/README.fr.md +0 -186
- package/docs/README.ja.md +0 -186
- package/docs/README.ko.md +0 -186
- package/docs/README.pt.md +0 -186
- package/docs/README.ru.md +0 -186
- package/docs/README.zh-CN.md +0 -190
- package/docs/README.zh-TW.md +0 -186
- package/src/agent-robot-avatar-antenna-flash.js +0 -127
|
@@ -374,8 +374,11 @@ const AgentRobotAvatar = (() => {
|
|
|
374
374
|
const near = Math.max(r.width, r.height) * 2.55;
|
|
375
375
|
const dist = Math.hypot(dx, dy);
|
|
376
376
|
const influence = clamp(1 - dist / near, 0, 1);
|
|
377
|
-
|
|
378
|
-
|
|
377
|
+
// Measure the pointer offset as if the avatar were demo-sized, so a large
|
|
378
|
+
// avatar does not look further than a small one for the same gesture.
|
|
379
|
+
const scale = Math.min(1, 120 / Math.max(1, r.width, r.height));
|
|
380
|
+
this._pointer.x = clamp(dx * .42 * scale, -48, 48);
|
|
381
|
+
this._pointer.y = clamp(dy * .38 * scale, -34, 34);
|
|
379
382
|
this._pointer.active = influence > 0.001;
|
|
380
383
|
this._pointer.influence = influence;
|
|
381
384
|
this._pointer.lastMove = performance.now();
|
|
@@ -42,6 +42,15 @@ proto.setPressSqueeze=function(enabled=true){
|
|
|
42
42
|
if(!enabled && this._gestureFx?.kind==='squeeze')this._cancelGesture();
|
|
43
43
|
return this;
|
|
44
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
|
+
};
|
|
45
54
|
proto.setAntennaDrag=function(enabled=true){
|
|
46
55
|
this.setAttribute('antenna-drag',String(Boolean(enabled)));
|
|
47
56
|
if(!enabled && this._gestureFx?.kind==='antenna-drag')this._cancelGesture();
|
|
@@ -24,28 +24,36 @@ const baseStopWaiting = proto.stopWaiting;
|
|
|
24
24
|
const baseDraw = proto._draw;
|
|
25
25
|
|
|
26
26
|
const DEFAULT_SIZE = 112;
|
|
27
|
+
// Canonical action names describe what the avatar does on screen. Older names that
|
|
28
|
+
// described a situation (send, warning, blocked, error, ...) still resolve to the same
|
|
29
|
+
// action, but print a one-time deprecation notice; they are removed in 1.0.
|
|
27
30
|
const ACTION_ALIASES = Object.freeze({
|
|
28
31
|
idle: 'idle',
|
|
29
32
|
bored: 'bored',
|
|
30
33
|
waiting: 'waiting',
|
|
34
|
+
'waiting-spin': 'waiting',
|
|
31
35
|
wait: 'waiting',
|
|
32
36
|
'waiting-wrap': 'waiting',
|
|
37
|
+
'waiting-wave': 'waiting',
|
|
33
38
|
input: 'input',
|
|
34
|
-
|
|
39
|
+
affirm: 'affirm',
|
|
40
|
+
send: 'affirm',
|
|
35
41
|
success: 'success',
|
|
36
42
|
failure: 'failure',
|
|
37
43
|
failed: 'failure',
|
|
38
44
|
fail: 'failure',
|
|
39
|
-
|
|
45
|
+
wary: 'wary',
|
|
46
|
+
warning: 'wary',
|
|
40
47
|
inspect: 'inspect',
|
|
41
48
|
verify: 'inspect',
|
|
42
49
|
review: 'inspect',
|
|
43
50
|
angry: 'angry',
|
|
44
|
-
blocked: '
|
|
45
|
-
'policy-blocked': '
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'
|
|
51
|
+
blocked: 'angry',
|
|
52
|
+
'policy-blocked': 'angry',
|
|
53
|
+
refuse: 'refuse',
|
|
54
|
+
error: 'refuse',
|
|
55
|
+
'system-error': 'refuse',
|
|
56
|
+
'connection-error': 'refuse',
|
|
49
57
|
surprise: 'surprise',
|
|
50
58
|
love: 'love',
|
|
51
59
|
random: 'random',
|
|
@@ -54,10 +62,44 @@ const ACTION_ALIASES = Object.freeze({
|
|
|
54
62
|
wake: 'wake',
|
|
55
63
|
});
|
|
56
64
|
|
|
65
|
+
const DEPRECATED_ACTIONS = Object.freeze({
|
|
66
|
+
wait: 'waiting-spin',
|
|
67
|
+
send: 'affirm',
|
|
68
|
+
failed: 'failure',
|
|
69
|
+
fail: 'failure',
|
|
70
|
+
warning: 'wary',
|
|
71
|
+
verify: 'inspect',
|
|
72
|
+
review: 'inspect',
|
|
73
|
+
blocked: 'angry',
|
|
74
|
+
'policy-blocked': 'angry',
|
|
75
|
+
error: 'refuse',
|
|
76
|
+
'system-error': 'refuse',
|
|
77
|
+
'connection-error': 'refuse',
|
|
78
|
+
slot: 'random',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The renamed actions still run on the original implementations.
|
|
82
|
+
const ACTION_IMPLEMENTATIONS = Object.freeze({
|
|
83
|
+
'waiting-spin': 'waiting',
|
|
84
|
+
affirm: 'send',
|
|
85
|
+
wary: 'warning',
|
|
86
|
+
refuse: 'error',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const warnedDeprecations = new Set();
|
|
90
|
+
|
|
91
|
+
function warnDeprecatedAction(raw) {
|
|
92
|
+
const replacement = DEPRECATED_ACTIONS[raw];
|
|
93
|
+
if (!replacement || warnedDeprecations.has(raw)) return;
|
|
94
|
+
warnedDeprecations.add(raw);
|
|
95
|
+
console.warn(`agent-robot-avatar: action "${raw}" is deprecated, use "${replacement}". Old names stop working in 1.0.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
function normalizeAction(name) {
|
|
58
99
|
const raw = String(name || '').trim().toLowerCase();
|
|
59
100
|
const canonical = Object.hasOwn(ACTION_ALIASES, raw) ? ACTION_ALIASES[raw] : null;
|
|
60
|
-
|
|
101
|
+
if (canonical) warnDeprecatedAction(raw);
|
|
102
|
+
return { raw, canonical, implementation: ACTION_IMPLEMENTATIONS[raw] ?? raw };
|
|
61
103
|
}
|
|
62
104
|
|
|
63
105
|
function wakePolicy(instance) {
|
|
@@ -402,15 +444,18 @@ function ensureRuntime(instance) {
|
|
|
402
444
|
if (record.attributeName === 'size') applySize(instance);
|
|
403
445
|
if (record.attributeName === 'motion') motionChanged = true;
|
|
404
446
|
if (record.attributeName === 'auto-sleep') autoSleepChanged = true;
|
|
447
|
+
if (record.attributeName === 'skin') instance._applySkinAttribute?.();
|
|
405
448
|
}
|
|
406
449
|
if (motionChanged) handleMotionChange(instance);
|
|
407
450
|
if (autoSleepChanged) scheduleAutoSleep(instance);
|
|
408
451
|
});
|
|
409
452
|
instance._runtimeAttributeObserver.observe(instance, {
|
|
410
453
|
attributes: true,
|
|
411
|
-
attributeFilter: ['size', 'motion', 'wake-on', 'auto-sleep'],
|
|
454
|
+
attributeFilter: ['size', 'motion', 'wake-on', 'auto-sleep', 'skin'],
|
|
412
455
|
});
|
|
413
456
|
}
|
|
457
|
+
// A skin set with setSkin() survives a reconnect; markup only applies when present.
|
|
458
|
+
if (instance.hasAttribute('skin')) instance._applySkinAttribute?.();
|
|
414
459
|
|
|
415
460
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
416
461
|
instance._runtimeIntersectionObserver = new IntersectionObserver(entries => {
|
|
@@ -717,17 +762,17 @@ function prepareReplacement(instance, canonical) {
|
|
|
717
762
|
|
|
718
763
|
proto.play = function(name) {
|
|
719
764
|
ensureRuntime(this);
|
|
720
|
-
const {
|
|
765
|
+
const { canonical, implementation } = normalizeAction(name);
|
|
721
766
|
if (!canonical) throw new Error(`Unknown Agent Robot Avatar action: ${name}`);
|
|
722
767
|
if (playNoOp(this, canonical)) return this;
|
|
723
|
-
if (canonical === 'idle') return basePlay.call(this,
|
|
768
|
+
if (canonical === 'idle') return basePlay.call(this, implementation);
|
|
724
769
|
|
|
725
770
|
prepareReplacement(this, canonical);
|
|
726
771
|
|
|
727
772
|
let result;
|
|
728
773
|
this._runtimeInPlay = true;
|
|
729
774
|
try {
|
|
730
|
-
result = basePlay.call(this,
|
|
775
|
+
result = basePlay.call(this, implementation);
|
|
731
776
|
} finally {
|
|
732
777
|
this._runtimeInPlay = false;
|
|
733
778
|
}
|
|
@@ -903,3 +948,4 @@ export {
|
|
|
903
948
|
DEFAULT_SIZE,
|
|
904
949
|
};
|
|
905
950
|
export default AgentRobotAvatar;
|
|
951
|
+
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Pattern tiles for skins. Each generator returns the tile size, an optional
|
|
2
|
+
// pattern transform and the SVG markup of one tile, in the head's 240-unit
|
|
3
|
+
// coordinates. Scattered patterns use a seeded random source so every avatar
|
|
4
|
+
// shows the same tile.
|
|
5
|
+
|
|
6
|
+
const rng = seed => {
|
|
7
|
+
let state = (seed % 2147483647) || 1;
|
|
8
|
+
return () => (state = state * 16807 % 2147483647) / 2147483647;
|
|
9
|
+
};
|
|
10
|
+
const n = value => Number(value.toFixed(2));
|
|
11
|
+
const pick = (random, list) => list[Math.floor(random() * list.length) % list.length];
|
|
12
|
+
|
|
13
|
+
// Repeats an element at the tile edges so scattered shapes wrap seamlessly.
|
|
14
|
+
function wrap(size, x, y, r, draw) {
|
|
15
|
+
let out = '';
|
|
16
|
+
for (const dx of [-size, 0, size]) for (const dy of [-size, 0, size]) {
|
|
17
|
+
if (x + dx + r < 0 || x + dx - r > size || y + dy + r < 0 || y + dy - r > size) continue;
|
|
18
|
+
out += draw(x + dx, y + dy);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Turns a point on the unit sphere, for the patterns drawn on balls.
|
|
24
|
+
function rotate([x, y, z], [yaw, pitch, roll]) {
|
|
25
|
+
let c = Math.cos(yaw), s = Math.sin(yaw);
|
|
26
|
+
[x, z] = [x * c + z * s, -x * s + z * c];
|
|
27
|
+
c = Math.cos(pitch); s = Math.sin(pitch);
|
|
28
|
+
[y, z] = [y * c - z * s, y * s + z * c];
|
|
29
|
+
c = Math.cos(roll); s = Math.sin(roll);
|
|
30
|
+
[x, y] = [x * c - y * s, x * s + y * c];
|
|
31
|
+
return [x, y, z];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const PATTERNS = {
|
|
39
|
+
tartan({ colors: [a, b, c], size = 56 }) {
|
|
40
|
+
const bands = [[0, 18, a, .55], [26, 3, b, .8], [38, 2, c, .8], [44, 6, a, .35]];
|
|
41
|
+
let body = '';
|
|
42
|
+
for (const [x, w, color, o] of bands) {
|
|
43
|
+
body += `<rect x="${x}" width="${w}" height="${size}" fill="${color}" opacity="${o}"/><rect y="${x}" width="${size}" height="${w}" fill="${color}" opacity="${o}"/>`;
|
|
44
|
+
}
|
|
45
|
+
return { size: [size, size], body };
|
|
46
|
+
},
|
|
47
|
+
dots({ colors: [c], r = 5, size = 26, opacity = 1 }) {
|
|
48
|
+
const q = size / 4;
|
|
49
|
+
return { size: [size, size], body: `<circle cx="${q}" cy="${q}" r="${r}" fill="${c}" opacity="${opacity}"/><circle cx="${q * 3}" cy="${q * 3}" r="${r}" fill="${c}" opacity="${opacity}"/>` };
|
|
50
|
+
},
|
|
51
|
+
terrazzo({ colors, count = 44, size = 120, seed = 5 }) {
|
|
52
|
+
const random = rng(seed);
|
|
53
|
+
let body = '';
|
|
54
|
+
for (let i = 0; i < count; i++) {
|
|
55
|
+
const x = random() * size, y = random() * size, k = 2.5 + random() * 6, sides = 3 + Math.floor(random() * 3), color = pick(random, colors), turn = random() * Math.PI;
|
|
56
|
+
let d = '';
|
|
57
|
+
for (let j = 0; j < sides; j++) {
|
|
58
|
+
const a = turn + j / sides * Math.PI * 2, rr = k * (.6 + random() * .5);
|
|
59
|
+
d += `${j ? 'L' : 'M'}${n(Math.cos(a) * rr)} ${n(Math.sin(a) * rr)}`;
|
|
60
|
+
}
|
|
61
|
+
body += wrap(size, x, y, k, (px, py) => `<path d="${d}Z" fill="${color}" transform="translate(${n(px)} ${n(py)})"/>`);
|
|
62
|
+
}
|
|
63
|
+
return { size: [size, size], body };
|
|
64
|
+
},
|
|
65
|
+
marble({ colors: [a, b], seed = 21 }) {
|
|
66
|
+
const random = rng(seed);
|
|
67
|
+
let body = '';
|
|
68
|
+
for (let i = 0; i < 7; i++) {
|
|
69
|
+
const color = i % 3 === 2 ? b : a;
|
|
70
|
+
let x = -20, y = random() * 240, d = `M${n(x)} ${n(y)}`;
|
|
71
|
+
while (x < 260) {
|
|
72
|
+
const nx = x + 30 + random() * 40, ny = y + (random() - .5) * 70;
|
|
73
|
+
d += `Q${n(x + 15 + random() * 20)} ${n(y + (random() - .5) * 60)} ${n(nx)} ${n(ny)}`;
|
|
74
|
+
x = nx; y = ny;
|
|
75
|
+
}
|
|
76
|
+
body += `<path d="${d}" fill="none" stroke="${color}" stroke-width="${n(.6 + random() * 1.8)}" opacity="${n(.35 + random() * .35)}"/>`;
|
|
77
|
+
body += `<path d="${d}" fill="none" stroke="${color}" stroke-width="${n(5 + random() * 6)}" opacity=".07"/>`;
|
|
78
|
+
}
|
|
79
|
+
return { size: [240, 240], body };
|
|
80
|
+
},
|
|
81
|
+
pixel({ colors, cell = 10, size = 80, seed = 31, opacity = .9 }) {
|
|
82
|
+
const random = rng(seed);
|
|
83
|
+
let body = '';
|
|
84
|
+
for (let y = 0; y < size; y += cell) for (let x = 0; x < size; x += cell) {
|
|
85
|
+
if (random() < .45) continue;
|
|
86
|
+
body += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="${pick(random, colors)}" opacity="${opacity}"/>`;
|
|
87
|
+
}
|
|
88
|
+
return { size: [size, size], body };
|
|
89
|
+
},
|
|
90
|
+
confetti({ colors, count = 38, size = 120, seed = 37 }) {
|
|
91
|
+
const random = rng(seed);
|
|
92
|
+
let body = '';
|
|
93
|
+
for (let i = 0; i < count; i++) {
|
|
94
|
+
const x = random() * size, y = random() * size, color = pick(random, colors), turn = random() * 180, strip = random() < .5;
|
|
95
|
+
body += wrap(size, x, y, 6, (px, py) => strip
|
|
96
|
+
? `<rect x="${n(px - 1.6)}" y="${n(py - 4)}" width="3.2" height="8" rx="1" fill="${color}" transform="rotate(${n(turn)} ${n(px)} ${n(py)})"/>`
|
|
97
|
+
: `<circle cx="${n(px)}" cy="${n(py)}" r="2.2" fill="${color}"/>`);
|
|
98
|
+
}
|
|
99
|
+
return { size: [size, size], body };
|
|
100
|
+
},
|
|
101
|
+
halftone({ colors: [c], step = 12, opacity = .7 }) {
|
|
102
|
+
let body = '';
|
|
103
|
+
for (let y = 6; y < 240; y += step) for (let x = 6 + ((y / step) % 2) * step / 2; x < 240; x += step) {
|
|
104
|
+
body += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(.6 + 4.4 * (y / 240))}" fill="${c}" opacity="${opacity}"/>`;
|
|
105
|
+
}
|
|
106
|
+
return { size: [240, 240], body };
|
|
107
|
+
},
|
|
108
|
+
scatter({ colors, shapes, count = 12, size = 120, k = [6, 10], seed = 47 }) {
|
|
109
|
+
const random = rng(seed);
|
|
110
|
+
let body = '';
|
|
111
|
+
for (let i = 0; i < count; i++) {
|
|
112
|
+
const x = random() * size, y = random() * size, r = k[0] + random() * (k[1] - k[0]), turn = (random() - .5) * 70;
|
|
113
|
+
const shape = pick(random, shapes), color = colors[i % colors.length], accent = colors[(i + 1) % colors.length];
|
|
114
|
+
const art = SHAPES[shape](r, color, accent);
|
|
115
|
+
body += wrap(size, x, y, r * 1.3, (px, py) => `<g transform="translate(${n(px)} ${n(py)}) rotate(${n(turn)})">${art}</g>`);
|
|
116
|
+
}
|
|
117
|
+
return { size: [size, size], body };
|
|
118
|
+
},
|
|
119
|
+
grooves({ colors: [line, shadow], radius = 138, angles = [.5, -.45, .2] }) {
|
|
120
|
+
// A modern ball: rings of fine grooves wrapping the surface in three directions.
|
|
121
|
+
let body = '';
|
|
122
|
+
for (const axis of [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) {
|
|
123
|
+
for (const lat of [-.42, 0, .42]) {
|
|
124
|
+
const pts = [];
|
|
125
|
+
for (let k = 0; k <= 72; k++) {
|
|
126
|
+
const a = k / 72 * Math.PI * 2, r = Math.cos(lat), h = Math.sin(lat);
|
|
127
|
+
const p = axis[0] ? [h, r * Math.cos(a), r * Math.sin(a)] : axis[1] ? [r * Math.cos(a), h, r * Math.sin(a)] : [r * Math.cos(a), r * Math.sin(a), h];
|
|
128
|
+
pts.push(rotate(p, angles));
|
|
129
|
+
}
|
|
130
|
+
let d = '';
|
|
131
|
+
pts.forEach(([x, y, z], i) => { d += `${i && z > 0 && pts[i - 1][2] > 0 ? 'L' : 'M'}${n(120 + x * radius)} ${n(120 + y * radius)}`; });
|
|
132
|
+
body += `<path d="${d}" fill="none" stroke="${shadow}" stroke-width="3" opacity=".6" transform="translate(.8 1)"/><path d="${d}" fill="none" stroke="${line}" stroke-width="1.6"/>`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { size: [240, 240], body };
|
|
136
|
+
},
|
|
137
|
+
// Balls: each is drawn once across the whole head instead of repeating.
|
|
138
|
+
basketball({ colors: [seam, grain] }) {
|
|
139
|
+
return {
|
|
140
|
+
size: [240, 240],
|
|
141
|
+
defs: tex => `<pattern id="${tex}" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r=".9" fill="${grain}" opacity=".32"/></pattern>`,
|
|
142
|
+
body: tex => `<rect width="240" height="240" fill="url(#${tex})"/><path d="M120 0V240M0 120H240M52 18Q100 120 52 222M188 18Q140 120 188 222" fill="none" stroke="#ffd2a6" stroke-width="2.4" stroke-linecap="round" opacity=".55" transform="translate(1.2 1.6)"/><path d="M120 0V240M0 120H240M52 18Q100 120 52 222M188 18Q140 120 188 222" fill="none" stroke="${seam}" stroke-width="3.4" stroke-linecap="round"/>`,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
tennis({ colors: [seam, fuzz] }) {
|
|
151
|
+
return {
|
|
152
|
+
size: [240, 240],
|
|
153
|
+
defs: tex => `<pattern id="${tex}" patternUnits="userSpaceOnUse" width="7" height="7"><circle cx="1.5" cy="2" r=".7" fill="${fuzz}" opacity=".35"/><circle cx="5" cy="5.5" r=".6" fill="#ffffff" opacity=".3"/></pattern>`,
|
|
154
|
+
body: tex => `<rect width="240" height="240" fill="url(#${tex})"/><path d="M14 62C92 34 92 206 14 178M226 62C148 34 148 206 226 178" fill="none" stroke="#f7ffd0" stroke-width="6.5" stroke-linecap="round" opacity=".45" transform="translate(1 1.6)"/><path d="M14 62C92 34 92 206 14 178M226 62C148 34 148 206 226 178" fill="none" stroke="#4a6000" stroke-width="6.5" stroke-linecap="round" opacity=".38" transform="translate(-1 -1.6)"/><path d="M14 62C92 34 92 206 14 178M226 62C148 34 148 206 226 178" fill="none" stroke="${seam}" stroke-width="5" stroke-linecap="round"/>`,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
baseball({ colors: [seam, stitch] }) {
|
|
158
|
+
const arc = (x0, cx) => {
|
|
159
|
+
let stitches = '';
|
|
160
|
+
for (let t = .06; t < .96; t += .055) {
|
|
161
|
+
const x = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x0, y = (1 - t) ** 2 * 26 + 2 * (1 - t) * t * 120 + t * t * 214;
|
|
162
|
+
const dx = 2 * (1 - t) * (cx - x0) + 2 * t * (x0 - cx), dy = 2 * (1 - t) * 94 + 2 * t * 94, len = Math.hypot(dx, dy);
|
|
163
|
+
const nx = -dy / len * 5, ny = dx / len * 5, tx = dx / len * 2.2, ty = dy / len * 2.2;
|
|
164
|
+
stitches += `M${n(x - nx - tx)} ${n(y - ny - ty)}L${n(x)} ${n(y)}L${n(x + nx - tx)} ${n(y + ny - ty)}`;
|
|
165
|
+
}
|
|
166
|
+
return `<path d="M${x0} 26Q${cx} 120 ${x0} 214" fill="none" stroke="${seam}" stroke-width="1.3"/><path d="${stitches}" fill="none" stroke="${stitch}" stroke-width="2" stroke-linecap="round"/>`;
|
|
167
|
+
};
|
|
168
|
+
return { size: [240, 240], body: arc(44, 104) + arc(196, 136) };
|
|
169
|
+
},
|
|
170
|
+
cricket({ colors: [seam, stitch] }) {
|
|
171
|
+
// A raised seam arching over the forehead with three rows of stitches each side.
|
|
172
|
+
const y = (x, lift) => 84 - Math.sin(x / 240 * Math.PI) * 26 + lift;
|
|
173
|
+
const curve = lift => {
|
|
174
|
+
let d = '';
|
|
175
|
+
for (let x = -4; x <= 244; x += 8) d += `${x < 0 ? 'M' : 'L'}${x} ${n(y(x, lift))}`;
|
|
176
|
+
return d;
|
|
177
|
+
};
|
|
178
|
+
let rows = '';
|
|
179
|
+
for (const lift of [-11, -7, -3, 3, 7, 11]) {
|
|
180
|
+
for (let x = 2; x < 240; x += 6) rows += `M${x} ${n(y(x, lift))}l2.6 -1.2`;
|
|
181
|
+
}
|
|
182
|
+
return { size: [240, 240], body: `<path d="${curve(0)}" fill="none" stroke="${seam}" stroke-width="15" stroke-linecap="round" opacity=".55"/><path d="${curve(0)}" fill="none" stroke="${seam}" stroke-width="2"/><path d="${rows}" fill="none" stroke="${stitch}" stroke-width="1.5" stroke-linecap="round"/>` };
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
golf({ colors: [shade, shine], r = 5.6 }) {
|
|
194
|
+
const w = 15, h = w * Math.sqrt(3);
|
|
195
|
+
const dimple = (x, y) => `<circle cx="${n(x)}" cy="${n(y)}" r="${r}" fill="${shade}" opacity=".55"/><circle cx="${n(x + .9)}" cy="${n(y + 1)}" r="${n(r * .55)}" fill="${shine}" opacity=".8"/>`;
|
|
196
|
+
return { size: [w, n(h)], body: dimple(0, 0) + dimple(w, 0) + dimple(w / 2, h / 2) + dimple(0, h) + dimple(w, h) };
|
|
197
|
+
},
|
|
198
|
+
football({ colors: [lace, grain, seam] }) {
|
|
199
|
+
// Lying on its side: laces run along the top, stripes ring both ends.
|
|
200
|
+
let stitches = '';
|
|
201
|
+
for (let x = 90; x <= 150; x += 10) stitches += `M${x} 44V60`;
|
|
202
|
+
return {
|
|
203
|
+
size: [240, 240],
|
|
204
|
+
defs: tex => `<pattern id="${tex}" patternUnits="userSpaceOnUse" width="5" height="5"><circle cx="2.5" cy="2.5" r=".8" fill="${grain}" opacity=".35"/></pattern>`,
|
|
205
|
+
body: tex => `<rect width="240" height="240" fill="url(#${tex})"/>`
|
|
206
|
+
+ `<path d="M-10 40Q120 62 250 40M-10 210Q120 188 250 210" fill="none" stroke="${seam}" stroke-width="1.4" opacity=".7"/>`
|
|
207
|
+
+ `<g transform="translate(1 1.6)" opacity=".35"><path d="M84 52H156" stroke="#1a0800" stroke-width="2"/><path d="${stitches}" stroke="#1a0800" stroke-width="3.6" stroke-linecap="round"/></g>`
|
|
208
|
+
+ `<path d="M84 52H156" stroke="${lace}" stroke-width="2"/><path d="${stitches}" stroke="${lace}" stroke-width="3.4" stroke-linecap="round"/>`
|
|
209
|
+
+ `<path d="M34 20Q46 120 34 220M46 20Q58 120 46 220M206 20Q194 120 206 220M194 20Q182 120 194 220" fill="none" stroke="${lace}" stroke-width="5"/>`,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const SHAPES = {
|
|
215
|
+
leaf: (k, c, accent) => `<path d="M0 ${n(-k)}Q${n(.65 * k)} 0 0 ${n(k)}Q${n(-.65 * k)} 0 0 ${n(-k)}Z" fill="${c}"/><path d="M0 ${n(-.8 * k)}V${n(.9 * k)}" stroke="${accent}" stroke-width="${n(.08 * k)}"/>`,
|
|
216
|
+
blossom: (k, c, accent) => `${[0, 72, 144, 216, 288].map(a => `<ellipse cy="${n(-.5 * k)}" rx="${n(.3 * k)}" ry="${n(.45 * k)}" fill="${c}" transform="rotate(${a})"/>`).join('')}<circle r="${n(.2 * k)}" fill="${accent}"/>`,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// A generator may also return defs (for example a fine texture tile) that its
|
|
220
|
+
// body refers to; both receive the texture id.
|
|
221
|
+
// Generated tiles are cached per spec; ball layouts take a moment to work out.
|
|
222
|
+
const tiles = new Map();
|
|
223
|
+
function patternMarkup(id, spec) {
|
|
224
|
+
const make = PATTERNS[spec.kind];
|
|
225
|
+
if (!make) return '';
|
|
226
|
+
const key = JSON.stringify(spec);
|
|
227
|
+
if (!tiles.has(key)) tiles.set(key, make(spec));
|
|
228
|
+
const { size: [w, h], transform, body, defs } = tiles.get(key);
|
|
229
|
+
const tex = `${id}-texture`;
|
|
230
|
+
const inner = typeof body === 'function' ? body(tex) : body;
|
|
231
|
+
return `${defs ? defs(tex) : ''}<pattern id="${id}" patternUnits="userSpaceOnUse" width="${w}" height="${h}"${transform ? ` patternTransform="${transform}"` : ''}>${inner}</pattern>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export { PATTERNS, patternMarkup };
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
|
|
2
|
+
import { SKIN_CATALOG, SKIN_SERIES } from './skins/catalog.js';
|
|
3
|
+
|
|
4
|
+
// The painters are large, so they load the first time a skin needs them. A page
|
|
5
|
+
// that never uses a skin never downloads them.
|
|
6
|
+
const painters = {};
|
|
7
|
+
const loading = {};
|
|
8
|
+
function loadPainter(kind) {
|
|
9
|
+
loading[kind] ??= Promise.all([
|
|
10
|
+
import('./skins/paints.js'),
|
|
11
|
+
kind === 'native' ? import('./skins/native.js') : import('./skins/material-library.js'),
|
|
12
|
+
import('./skins/eyelids.js'),
|
|
13
|
+
]).then(([paints, painter, eyelids]) => {
|
|
14
|
+
painters.paints = paints.SKIN_PAINTS;
|
|
15
|
+
painters.maskEyelids = eyelids.maskEyelids;
|
|
16
|
+
painters[kind] = painter;
|
|
17
|
+
});
|
|
18
|
+
return loading[kind];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Loads every skin painter now, so later skins show on the same frame they are set. */
|
|
22
|
+
function loadSkins() {
|
|
23
|
+
return Promise.all([loadPainter('native'), loadPainter('material')]).then(() => undefined);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proto = AgentRobotAvatar.prototype;
|
|
27
|
+
const baseApplyColor = proto._applyColor;
|
|
28
|
+
|
|
29
|
+
// Skins are numbered. The number is the permanent public name; the Chinese and
|
|
30
|
+
// English titles are for display and may be reworded later. SKIN_NAMES lists
|
|
31
|
+
// them in display order, series by series.
|
|
32
|
+
const SKIN_NAMES = Object.freeze(SKIN_SERIES.flatMap(series => series.skins));
|
|
33
|
+
const SKINS = Object.freeze(Object.fromEntries(Object.entries(SKIN_CATALOG).map(([id, skin]) => [id,
|
|
34
|
+
Object.freeze({ series: skin.series, title: skin.zh, titleEn: skin.en, base: Object.freeze([...skin.base]) })])));
|
|
35
|
+
const SERIES = Object.freeze(SKIN_SERIES.map(series => Object.freeze({
|
|
36
|
+
id: series.id, number: series.number, title: series.zh, titleEn: series.en, skins: Object.freeze([...series.skins]),
|
|
37
|
+
})));
|
|
38
|
+
|
|
39
|
+
const REST_LIGHT = Object.freeze({ x: -0.5, y: -0.5 });
|
|
40
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
41
|
+
|
|
42
|
+
function normalizeSkin(value) {
|
|
43
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
44
|
+
if (!text || text === 'none') return null;
|
|
45
|
+
const name = /^\d+$/.test(text) ? String(Number(text)) : text;
|
|
46
|
+
return Object.hasOwn(SKIN_CATALOG, name) ? name : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Hand adjustments from the skin workshop, applied after a skin is painted.
|
|
50
|
+
// A record names an element by id or selector, optionally a child path, and the
|
|
51
|
+
// attributes to set on it.
|
|
52
|
+
function findPainted(root, record) {
|
|
53
|
+
let node = record.id ? root.getElementById(record.id) : root.querySelector(record.selector);
|
|
54
|
+
for (const index of record.path || []) node = node?.children[index];
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Some adjustments touch layers every skin shares, so each change is recorded
|
|
59
|
+
// and undone before the next skin is painted.
|
|
60
|
+
function paintAdjustments(instance, name) {
|
|
61
|
+
const records = painters.paints[name]?.paint || [];
|
|
62
|
+
const root = instance.shadowRoot;
|
|
63
|
+
const undo = instance._skinUndo ??= [];
|
|
64
|
+
for (const record of records) {
|
|
65
|
+
const node = findPainted(root, record);
|
|
66
|
+
if (!node) continue;
|
|
67
|
+
for (const [attribute, value] of Object.entries(record.attributes)) {
|
|
68
|
+
undo.push(() => {
|
|
69
|
+
if (old === null) node.removeAttribute(attribute);
|
|
70
|
+
else node.setAttribute(attribute, old);
|
|
71
|
+
});
|
|
72
|
+
const old = node.getAttribute(attribute);
|
|
73
|
+
node.setAttribute(attribute, value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const [property, value] of Object.entries(painters.paints[name]?.properties || {})) {
|
|
77
|
+
const old = instance.style.getPropertyValue(property);
|
|
78
|
+
undo.push(() => old ? instance.style.setProperty(property, old) : instance.style.removeProperty(property));
|
|
79
|
+
instance.style.setProperty(property, value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function undoAdjustments(instance) {
|
|
84
|
+
const undo = instance._skinUndo;
|
|
85
|
+
while (undo?.length) undo.pop()();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Adjustments to light-driven attributes were made with the light at rest. They
|
|
89
|
+
// become offsets so they move with the light instead of being overwritten.
|
|
90
|
+
function adjustNative(instance, skin, name) {
|
|
91
|
+
const { lightNative } = painters.native;
|
|
92
|
+
const { x, y } = skin.light;
|
|
93
|
+
lightNative(instance, REST_LIGHT.x, REST_LIGHT.y);
|
|
94
|
+
const resting = Object.entries(skin.nodes).map(([key, node]) => {
|
|
95
|
+
const attribute = key.slice(key.lastIndexOf('.') + 1);
|
|
96
|
+
return { key, node, attribute, value: parseFloat(node.getAttribute(attribute)) };
|
|
97
|
+
});
|
|
98
|
+
paintAdjustments(instance, name);
|
|
99
|
+
for (const { key, node, attribute, value } of resting) {
|
|
100
|
+
const adjusted = parseFloat(node.getAttribute(attribute));
|
|
101
|
+
if (Number.isFinite(adjusted) && Number.isFinite(value) && adjusted !== value) skin.offsets[key] = adjusted - value;
|
|
102
|
+
}
|
|
103
|
+
skin.shown = {};
|
|
104
|
+
lightNative(instance, x, y);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function adjustMaterial(instance, material, name) {
|
|
108
|
+
const records = painters.paints[name]?.paint || [];
|
|
109
|
+
const root = instance.shadowRoot;
|
|
110
|
+
const key = SKIN_CATALOG[name].material;
|
|
111
|
+
const offsets = [];
|
|
112
|
+
for (const lightKey of material.lightKeys || []) {
|
|
113
|
+
const [suffix, attribute] = lightKey.split(':');
|
|
114
|
+
const record = records.find(r => r.id === `material-${key}-${suffix}` && !r.path?.length);
|
|
115
|
+
const node = record && findPainted(root, record);
|
|
116
|
+
const value = record?.attributes[attribute];
|
|
117
|
+
if (node && value != null) offsets.push({ node, attribute, delta: parseFloat(value) - parseFloat(node.getAttribute(attribute)) });
|
|
118
|
+
}
|
|
119
|
+
paintAdjustments(instance, name);
|
|
120
|
+
const light = material.light.bind(material);
|
|
121
|
+
material.light = (x, y) => {
|
|
122
|
+
light(x, y);
|
|
123
|
+
for (const { node, attribute, delta } of offsets) node.setAttribute(attribute, parseFloat(node.getAttribute(attribute)) + delta);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function render(instance) {
|
|
128
|
+
const name = instance._skinName ?? null;
|
|
129
|
+
if (instance._skinShown === name || !instance._head) return;
|
|
130
|
+
const entry = name ? SKIN_CATALOG[name] : null;
|
|
131
|
+
const kind = entry ? (entry.native ? 'native' : 'material') : null;
|
|
132
|
+
if (kind && !painters[kind]) {
|
|
133
|
+
// Draw it once the painter arrives, unless another skin was chosen meanwhile.
|
|
134
|
+
if (instance._skinPending !== name) {
|
|
135
|
+
instance._skinPending = name;
|
|
136
|
+
loadPainter(kind).then(() => {
|
|
137
|
+
instance._skinPending = undefined;
|
|
138
|
+
if ((instance._skinName ?? null) === name) render(instance);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const { showNative } = painters.native || {};
|
|
144
|
+
undoAdjustments(instance);
|
|
145
|
+
instance._material?.dispose();
|
|
146
|
+
instance._material = null;
|
|
147
|
+
if (!entry?.native) showNative?.(instance, null);
|
|
148
|
+
instance._skinShown = name;
|
|
149
|
+
// Without a skin the head takes the current color attribute again.
|
|
150
|
+
if (!entry) {
|
|
151
|
+
baseApplyColor.call(instance, instance.getAttribute('color'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (entry.native) {
|
|
155
|
+
// Frames may stop as soon as love ends, so the joined heart is split here.
|
|
156
|
+
if (!instance._skinLoveEnd) {
|
|
157
|
+
instance._skinLoveEnd = event => {
|
|
158
|
+
if (event.detail?.state !== 'love') painters.native?.separateNativeEyes(instance);
|
|
159
|
+
};
|
|
160
|
+
instance.addEventListener('face-state', instance._skinLoveEnd);
|
|
161
|
+
}
|
|
162
|
+
const skin = showNative(instance, entry.native);
|
|
163
|
+
if (skin) {
|
|
164
|
+
const light = instance._skinLight;
|
|
165
|
+
if (light) { skin.light.x = light.x; skin.light.y = light.y; }
|
|
166
|
+
adjustNative(instance, skin, name);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// The lids cut the eyes through a mask, so the head paint shows behind them.
|
|
170
|
+
painters.maskEyelids(instance);
|
|
171
|
+
const material = painters.material.applyMaterial(instance, entry.material);
|
|
172
|
+
adjustMaterial(instance, material, name);
|
|
173
|
+
instance._material = material;
|
|
174
|
+
instance._materialLight = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The light follows the pointer the same way the eyes do, and rests at the
|
|
179
|
+
// upper left when the pointer is away, following is off, or motion is reduced.
|
|
180
|
+
function updateLight(instance) {
|
|
181
|
+
const name = instance._skinShown;
|
|
182
|
+
if (!name) return;
|
|
183
|
+
const pointer = instance._pointer;
|
|
184
|
+
const reduced = instance._isReducedMotion?.();
|
|
185
|
+
const follows = instance._pointerFollowEnabled !== false && !reduced && pointer?.active && pointer.lastMove > 0;
|
|
186
|
+
const pull = follows ? Math.pow(clamp(pointer.influence || 0, 0, 1), .7) : 0;
|
|
187
|
+
// Pages can pin the light with _skinFixedLight = { x, y } in [-1, 1].
|
|
188
|
+
const fixed = instance._skinFixedLight;
|
|
189
|
+
const tx = fixed ? fixed.x : REST_LIGHT.x + (clamp(pointer?.x / 48, -1, 1) - REST_LIGHT.x) * pull;
|
|
190
|
+
const ty = fixed ? fixed.y : REST_LIGHT.y + (clamp(pointer?.y / 34, -1, 1) - REST_LIGHT.y) * pull;
|
|
191
|
+
const light = instance._skinLight ??= { ...REST_LIGHT };
|
|
192
|
+
if (reduced) {
|
|
193
|
+
light.x = tx;
|
|
194
|
+
light.y = ty;
|
|
195
|
+
} else {
|
|
196
|
+
light.x += ((Number.isFinite(tx) ? tx : REST_LIGHT.x) - light.x) * .18;
|
|
197
|
+
light.y += ((Number.isFinite(ty) ? ty : REST_LIGHT.y) - light.y) * .18;
|
|
198
|
+
}
|
|
199
|
+
if (SKIN_CATALOG[name].native) {
|
|
200
|
+
painters.native.joinNativeEyes(instance);
|
|
201
|
+
painters.native.lightNative(instance, light.x, light.y);
|
|
202
|
+
} else if (instance._material) {
|
|
203
|
+
const last = instance._materialLight;
|
|
204
|
+
if (last && Math.abs(last.x - light.x) < .0005 && Math.abs(last.y - light.y) < .0005) return;
|
|
205
|
+
instance._materialLight = { x: light.x, y: light.y };
|
|
206
|
+
instance._material.light(light.x, light.y);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The runtime's attribute observer calls this when the skin attribute changes
|
|
211
|
+
// and once on connect. An unknown name in markup falls back to no skin with a
|
|
212
|
+
// console notice, so an invalid attribute never breaks the page; setSkin()
|
|
213
|
+
// throws instead.
|
|
214
|
+
proto._applySkinAttribute = function() {
|
|
215
|
+
const value = this.getAttribute('skin');
|
|
216
|
+
if (normalizeSkin(value) === undefined) {
|
|
217
|
+
console.warn(`agent-robot-avatar: unknown skin "${value}". Use a skin number such as ${SKIN_NAMES[0]}, or none.`);
|
|
218
|
+
this.setSkin(null);
|
|
219
|
+
} else {
|
|
220
|
+
this.setSkin(value);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
proto.setSkin = function(value = null) {
|
|
225
|
+
const name = normalizeSkin(value);
|
|
226
|
+
if (name === undefined) throw new Error(`Unknown Agent Robot Avatar skin: ${value}`);
|
|
227
|
+
this._skinName = name;
|
|
228
|
+
render(this);
|
|
229
|
+
this._resumeFrames?.();
|
|
230
|
+
return this;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
proto.getSkin = function() {
|
|
234
|
+
return this._skinName ?? null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// A skin keeps the head paint even when the color attribute changes; removing
|
|
238
|
+
// the skin brings the color back.
|
|
239
|
+
proto._applyColor = function(value) {
|
|
240
|
+
baseApplyColor.call(this, value);
|
|
241
|
+
const entry = this._skinShown ? SKIN_CATALOG[this._skinShown] : null;
|
|
242
|
+
if (!entry || !this._head) return;
|
|
243
|
+
this._head.setAttribute('fill', entry.native ? 'url(#skin-base)' : `url(#material-${entry.material}-base)`);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
registerAvatarExtension({
|
|
247
|
+
name: 'skins',
|
|
248
|
+
draw() {
|
|
249
|
+
// A skin set before the avatar was ready is shown on its first frame.
|
|
250
|
+
if ((this._skinName ?? null) !== (this._skinShown ?? null)) render(this);
|
|
251
|
+
updateLight(this);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export { AgentRobotAvatar, SKINS, SKIN_NAMES, SERIES as SKIN_SERIES, loadSkins };
|
|
256
|
+
export default AgentRobotAvatar;
|