peepface 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +213 -2
- package/core.js +328 -0
- package/dist/dsh-client.js +665 -0
- package/dist/peepface.js +329 -0
- package/dist/peepface.mjs +10 -0
- package/package.json +52 -3
- package/peepface.d.ts +43 -0
- package/states.json +274 -0
package/core.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/* peepface core — 工厂版
|
|
2
|
+
* createFace(container, options) → { setState, getState, destroy }
|
|
3
|
+
* 无状态判定、无全局单例,可在一页里同时跑多个实例。
|
|
4
|
+
*/
|
|
5
|
+
(function (root) {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const DEG = Math.PI / 180;
|
|
9
|
+
const SAMPLES = 64;
|
|
10
|
+
const R = 56;
|
|
11
|
+
|
|
12
|
+
/* ---------------- 几何 ---------------- */
|
|
13
|
+
|
|
14
|
+
const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
|
|
15
|
+
const norm = v => { const l = Math.hypot(...v) || 1; return [v[0]/l, v[1]/l, v[2]/l]; };
|
|
16
|
+
|
|
17
|
+
const sphere = (lon, lat) => {
|
|
18
|
+
const lo = lon * DEG, la = lat * DEG;
|
|
19
|
+
return [Math.sin(lo)*Math.cos(la), Math.sin(la), Math.cos(lo)*Math.cos(la)];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const rotate = (n, yaw, pitch) => {
|
|
23
|
+
const a = yaw * DEG, b = pitch * DEG;
|
|
24
|
+
const x1 = n[0]*Math.cos(a) + n[2]*Math.sin(a);
|
|
25
|
+
const z1 = -n[0]*Math.sin(a) + n[2]*Math.cos(a);
|
|
26
|
+
const y2 = n[1]*Math.cos(b) - z1*Math.sin(b);
|
|
27
|
+
const z2 = n[1]*Math.sin(b) + z1*Math.cos(b);
|
|
28
|
+
return [x1, y2, z2];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const tangentFrame = n => {
|
|
32
|
+
let u = cross([0, 1, 0], n);
|
|
33
|
+
if (Math.hypot(...u) < 1e-6) u = [1, 0, 0];
|
|
34
|
+
u = norm(u);
|
|
35
|
+
return { u, v: norm(cross(n, u)) };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/* 椭圆眼形:用两段 A 命令,带旋转角。
|
|
39
|
+
之前的 capsule 实现在 hw > hh(横条)时两端圆心重合,会退化成圆。 */
|
|
40
|
+
function eyePath(cx, cy, ux, uy, hw, hh) {
|
|
41
|
+
const theta = Math.atan2(uy, ux) * 180 / Math.PI;
|
|
42
|
+
const p0x = cx + ux * hw, p0y = cy + uy * hw;
|
|
43
|
+
const p1x = cx - ux * hw, p1y = cy - uy * hw;
|
|
44
|
+
return 'M' + p0x.toFixed(2) + ' ' + p0y.toFixed(2) +
|
|
45
|
+
'A' + hw.toFixed(2) + ' ' + hh.toFixed(2) + ' ' + theta.toFixed(1) +
|
|
46
|
+
' 0 1 ' + p1x.toFixed(2) + ' ' + p1y.toFixed(2) +
|
|
47
|
+
'A' + hw.toFixed(2) + ' ' + hh.toFixed(2) + ' ' + theta.toFixed(1) +
|
|
48
|
+
' 0 1 ' + p0x.toFixed(2) + ' ' + p0y.toFixed(2) + 'Z';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ---------------- 形体 ---------------- */
|
|
52
|
+
|
|
53
|
+
const SHAPES = {
|
|
54
|
+
circle: () => 1,
|
|
55
|
+
blob: th => 1 + 0.05*Math.cos(3*th + 0.6) + 0.02*Math.cos(5*th - 1.1),
|
|
56
|
+
tall: th => { const a=0.90, b=1.12, c=Math.cos(th), s=Math.sin(th); return a*b/Math.hypot(b*c, a*s); },
|
|
57
|
+
wide: th => { const a=1.14, b=0.88, c=Math.cos(th), s=Math.sin(th); return a*b/Math.hypot(b*c, a*s); },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const TABLE = {};
|
|
61
|
+
for (const k in SHAPES) {
|
|
62
|
+
TABLE[k] = Array.from({ length: SAMPLES }, (_, i) => SHAPES[k](i / SAMPLES * Math.PI * 2));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function radiusPath(radii, scale) {
|
|
66
|
+
let d = '';
|
|
67
|
+
for (let i = 0; i < SAMPLES; i++) {
|
|
68
|
+
const th = i / SAMPLES * Math.PI * 2;
|
|
69
|
+
const r = radii[i] * R * scale;
|
|
70
|
+
d += (i ? 'L' : 'M') + (Math.cos(th) * r).toFixed(1) + ' ' + (Math.sin(th) * r).toFixed(1);
|
|
71
|
+
}
|
|
72
|
+
return d + 'Z';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ---------------- 表情 ---------------- */
|
|
76
|
+
|
|
77
|
+
const EXPR = {
|
|
78
|
+
neutral: { w: 11.5, h: 24, tilt: -13 },
|
|
79
|
+
sad: { w: 11.5, h: 21, tilt: -28 },
|
|
80
|
+
focused: { w: 19, h: 8.5, tilt: 0 },
|
|
81
|
+
happy: { w: 16, h: 7, tilt: -18 },
|
|
82
|
+
surprised: { w: 13, h: 29, tilt: -8 },
|
|
83
|
+
sleepy: { w: 20, h: 3.2, tilt: -2 },
|
|
84
|
+
confused: { w: 10.5, h: 19, tilt: 30 },
|
|
85
|
+
droop: { w: 11, h: 16, tilt: -44 },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/* ---------------- 状态(来自 states.json) ---------------- */
|
|
89
|
+
|
|
90
|
+
const STATES = {
|
|
91
|
+
idle: { expr:'neutral', shape:'circle', gaze:[0, 0.05], breath:0.008, blink:[2.6, 6.0] },
|
|
92
|
+
listening: { expr:'neutral', shape:'circle', gaze:[0, 0.60], breath:0.010, blink:[2.4, 5.0] },
|
|
93
|
+
thinking: { expr:'sad', shape:'blob', gaze:[0, 0.60], breath:0.014, blink:[2.0, 4.0], ring:1 },
|
|
94
|
+
searching: { expr:'focused', shape:'circle', gaze:[0, 0.10], breath:0.012, blink:[3.0, 6.0], scan:1 },
|
|
95
|
+
streaming: { expr:'neutral', shape:'circle', gaze:[0, 0.15], breath:0.012, blink:[3.0, 6.0] },
|
|
96
|
+
working: { expr:'focused', shape:'circle', gaze:[0, 0.15], breath:0.020, blink:[3.0, 7.0] },
|
|
97
|
+
waiting: { expr:'neutral', shape:'blob', gaze:[0.55, 0.05], breath:0.006, blink:[3.0, 7.0], ring:0.5 },
|
|
98
|
+
blocked: { expr:'confused', shape:'tall', gaze:[-0.40, 0.10], breath:0.005, blink:[4.0, 8.0] },
|
|
99
|
+
finished: { expr:'happy', shape:'wide', gaze:[0, 0.20], breath:0.006, blink:[3.0, 6.0] },
|
|
100
|
+
failed: { expr:'droop', shape:'circle', gaze:[0, 0.42], breath:0.004, blink:[5.0, 9.0] },
|
|
101
|
+
sleeping: { expr:'sleepy', shape:'circle', gaze:[0, -0.20], breath:0.004, blink:[9, 16], asleep:1 },
|
|
102
|
+
waking: { expr:'surprised', shape:'circle', gaze:[0, 0.35], breath:0.016, blink:[1.4, 2.6] },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const ease = (cur, tgt, tau, dt) => cur + (tgt - cur) * (1 - Math.exp(-dt / tau));
|
|
106
|
+
|
|
107
|
+
/* ---------------- 工厂 ---------------- */
|
|
108
|
+
|
|
109
|
+
let uid = 0;
|
|
110
|
+
|
|
111
|
+
function createFace(container, options) {
|
|
112
|
+
if (!container) throw new Error('peepface: container required');
|
|
113
|
+
const opts = options || {};
|
|
114
|
+
const size = opts.size || 84;
|
|
115
|
+
const dark = !!opts.dark;
|
|
116
|
+
const follow = opts.follow !== false;
|
|
117
|
+
const id = 'pf' + (++uid);
|
|
118
|
+
|
|
119
|
+
const host = document.createElement('div');
|
|
120
|
+
host.className = 'peepface';
|
|
121
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
122
|
+
|
|
123
|
+
shadow.innerHTML = `
|
|
124
|
+
<style>
|
|
125
|
+
:host { all: initial; }
|
|
126
|
+
.wrap {
|
|
127
|
+
position: relative; width: ${size}px; height: ${size}px;
|
|
128
|
+
display: block; user-select: none;
|
|
129
|
+
}
|
|
130
|
+
svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
|
131
|
+
.face { fill: ${dark ? '#e8e8e8' : '#0a0a0c'}; }
|
|
132
|
+
.ring { fill: none; stroke: ${dark ? '#e8e8e8' : '#0a0a0c'}; stroke-width: 1.4; opacity: 0; }
|
|
133
|
+
</style>
|
|
134
|
+
<div class="wrap">
|
|
135
|
+
<svg viewBox="-70 -70 140 140">
|
|
136
|
+
<defs>
|
|
137
|
+
<mask id="${id}-holes" maskUnits="userSpaceOnUse" x="-70" y="-70" width="140" height="140">
|
|
138
|
+
<path id="${id}-body-mask" fill="#fff" d=""/>
|
|
139
|
+
<path id="${id}-eye-l" fill="#000" d=""/>
|
|
140
|
+
<path id="${id}-eye-r" fill="#000" d=""/>
|
|
141
|
+
</mask>
|
|
142
|
+
</defs>
|
|
143
|
+
<path id="${id}-ring" class="ring" d=""/>
|
|
144
|
+
<path id="${id}-body" class="face" mask="url(#${id}-holes)" d=""/>
|
|
145
|
+
</svg>
|
|
146
|
+
</div>`;
|
|
147
|
+
|
|
148
|
+
const el = {
|
|
149
|
+
body: shadow.getElementById(id + '-body'),
|
|
150
|
+
bodyMask: shadow.getElementById(id + '-body-mask'),
|
|
151
|
+
eyeL: shadow.getElementById(id + '-eye-l'),
|
|
152
|
+
eyeR: shadow.getElementById(id + '-eye-r'),
|
|
153
|
+
ring: shadow.getElementById(id + '-ring'),
|
|
154
|
+
wrap: shadow.querySelector('.wrap'),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const S = {
|
|
158
|
+
state: opts.state && STATES[opts.state] ? opts.state : 'idle',
|
|
159
|
+
yaw: 0, pitch: 0,
|
|
160
|
+
expr: 'neutral', shape: 'circle',
|
|
161
|
+
radii: TABLE.circle.slice(),
|
|
162
|
+
blink: 0, blinkT: -1, blinkDur: 0.26, nextBlink: 2 + Math.random() * 3,
|
|
163
|
+
breath: 0.008, ring: 0, tRing: 0,
|
|
164
|
+
pointer: null, time: Math.random() * 5, morph: null, asleep: 0,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
function applyState(name) {
|
|
168
|
+
const st = STATES[name] || STATES.idle;
|
|
169
|
+
S.state = name;
|
|
170
|
+
S.expr = st.expr;
|
|
171
|
+
S.shape = st.shape;
|
|
172
|
+
S.tRing = st.ring || 0;
|
|
173
|
+
S.breath = st.breath;
|
|
174
|
+
S.asleep = st.asleep ? 1 : 0;
|
|
175
|
+
if (st.blink) S.nextBlink = st.blink[0] + Math.random() * (st.blink[1] - st.blink[0]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function blink(dur) {
|
|
179
|
+
if (S.blinkT >= 0 && S.blinkT < 0.5) return;
|
|
180
|
+
S.blinkT = 0; S.blinkDur = dur || 0.26;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function onPointer(e) {
|
|
184
|
+
if (!follow) return;
|
|
185
|
+
const r = el.wrap.getBoundingClientRect();
|
|
186
|
+
if (!r.width) return;
|
|
187
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
188
|
+
const half = Math.max(r.width, 1);
|
|
189
|
+
let gx = (e.clientX - cx) / half, gy = -(e.clientY - cy) / half;
|
|
190
|
+
const len = Math.hypot(gx, gy);
|
|
191
|
+
if (len > 1) { gx /= len; gy /= len; }
|
|
192
|
+
S.pointer = [gx, gy];
|
|
193
|
+
}
|
|
194
|
+
const onLeave = () => { S.pointer = null; };
|
|
195
|
+
|
|
196
|
+
if (follow) {
|
|
197
|
+
window.addEventListener('pointermove', onPointer, { passive: true });
|
|
198
|
+
document.addEventListener('mouseleave', onLeave);
|
|
199
|
+
window.addEventListener('blur', onLeave);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let last = performance.now();
|
|
203
|
+
let alive = true;
|
|
204
|
+
|
|
205
|
+
function tick(now) {
|
|
206
|
+
const dt = Math.min(0.05, (now - last) / 1000);
|
|
207
|
+
last = now; S.time += dt;
|
|
208
|
+
|
|
209
|
+
const cur = STATES[S.state];
|
|
210
|
+
const bias = cur.gaze || [0, 0];
|
|
211
|
+
|
|
212
|
+
let ty, tp;
|
|
213
|
+
if (S.pointer) {
|
|
214
|
+
ty = S.pointer[0] * 40 + bias[0] * 18;
|
|
215
|
+
tp = -S.pointer[1] * 28 - bias[1] * 16;
|
|
216
|
+
} else {
|
|
217
|
+
ty = Math.sin(S.time * 0.42) * 8 + Math.sin(S.time * 0.17 + 1.3) * 4 + bias[0] * 18;
|
|
218
|
+
tp = Math.cos(S.time * 0.31) * 5 - bias[1] * 16;
|
|
219
|
+
}
|
|
220
|
+
if (cur.scan) ty += Math.sin(S.time * 3.6) * 30;
|
|
221
|
+
|
|
222
|
+
S.yaw = ease(S.yaw, ty, 0.08, dt);
|
|
223
|
+
S.pitch = ease(S.pitch, tp, 0.08, dt);
|
|
224
|
+
|
|
225
|
+
if (S.asleep) {
|
|
226
|
+
S.blink = 0; // 闭眼交给 sleepy 眼形本身表达
|
|
227
|
+
S.blinkT = -1;
|
|
228
|
+
S.nextBlink = 1e9; // 睡着不眨
|
|
229
|
+
} else if (S.blinkT >= 0) {
|
|
230
|
+
S.blinkT += dt / S.blinkDur;
|
|
231
|
+
if (S.blinkT >= 1) { S.blinkT = -1; S.blink = 0; }
|
|
232
|
+
else {
|
|
233
|
+
const t = S.blinkT, U1 = 0.42, U2 = 0.56;
|
|
234
|
+
const ss = x => x * x * (3 - 2 * x);
|
|
235
|
+
S.blink = t < U1 ? ss(t / U1) : t <= U2 ? 1 : 1 - ss((t - U2) / (1 - U2));
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
S.nextBlink -= dt;
|
|
239
|
+
if (S.nextBlink <= 0) {
|
|
240
|
+
blink(0.26);
|
|
241
|
+
const b = cur.blink || [2.6, 6];
|
|
242
|
+
S.nextBlink = b[0] + Math.random() * (b[1] - b[0]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const target = TABLE[S.shape] || TABLE.circle;
|
|
247
|
+
for (let i = 0; i < SAMPLES; i++) S.radii[i] = ease(S.radii[i], target[i], 0.12, dt);
|
|
248
|
+
|
|
249
|
+
const scale = 1 + Math.sin(S.time * 1.4) * S.breath;
|
|
250
|
+
const d = radiusPath(S.radii, scale);
|
|
251
|
+
el.body.setAttribute('d', d);
|
|
252
|
+
el.bodyMask.setAttribute('d', d);
|
|
253
|
+
|
|
254
|
+
const ex = EXPR[S.expr] || EXPR.neutral;
|
|
255
|
+
const eyes = [{ id: 'eyeL', lon: -27 }, { id: 'eyeR', lon: 27 }];
|
|
256
|
+
for (let k = 0; k < eyes.length; k++) {
|
|
257
|
+
const e = eyes[k];
|
|
258
|
+
const n = rotate(sphere(e.lon, 10), S.yaw, S.pitch);
|
|
259
|
+
const px = n[0] * R, py = -n[1] * R;
|
|
260
|
+
const tf = tangentFrame(n);
|
|
261
|
+
const uS = [tf.u[0], -tf.u[1]], vS = [tf.v[0], -tf.v[1]];
|
|
262
|
+
const uz = Math.hypot(uS[0], uS[1]) || 1e-4;
|
|
263
|
+
const vz = Math.hypot(vS[0], vS[1]) || 1e-4;
|
|
264
|
+
const ux = uS[0] / uz, uy = uS[1] / uz;
|
|
265
|
+
const roll = -S.yaw * 0.28 + ex.tilt;
|
|
266
|
+
const cR = Math.cos(roll * DEG), sR = Math.sin(roll * DEG);
|
|
267
|
+
const wx = ux * cR - uy * sR, wy = ux * sR + uy * cR;
|
|
268
|
+
const hw = (ex.w / 2) * Math.max(0.22, uz);
|
|
269
|
+
const hh = Math.max(0.3, (ex.h / 2) * vz * (1 - S.blink * 0.995));
|
|
270
|
+
el[e.id].setAttribute('d', eyePath(px, py, wx, wy, hw, hh));
|
|
271
|
+
const vis = Math.max(0, Math.min(1, (n[2] - 0.02) / 0.22));
|
|
272
|
+
el[e.id].setAttribute('opacity', vis.toFixed(2));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
S.ring = ease(S.ring, S.tRing, 0.25, dt);
|
|
276
|
+
if (S.ring > 0.02) {
|
|
277
|
+
const rx = 74, ry = 26, rot = -18 * DEG, a0 = S.time * 0.9;
|
|
278
|
+
let p = '';
|
|
279
|
+
for (let i = 0; i <= 60; i++) {
|
|
280
|
+
const a = i / 60 * Math.PI * 2 + a0;
|
|
281
|
+
const x0 = Math.cos(a) * rx, y0 = Math.sin(a) * ry;
|
|
282
|
+
p += (i ? 'L' : 'M') + (x0 * Math.cos(rot) - y0 * Math.sin(rot)).toFixed(1) + ' '
|
|
283
|
+
+ (x0 * Math.sin(rot) + y0 * Math.cos(rot)).toFixed(1);
|
|
284
|
+
}
|
|
285
|
+
el.ring.setAttribute('d', p + 'Z');
|
|
286
|
+
el.ring.style.opacity = (S.ring * 0.45).toFixed(2);
|
|
287
|
+
} else {
|
|
288
|
+
el.ring.style.opacity = 0;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function frame(now) {
|
|
293
|
+
if (!alive) return;
|
|
294
|
+
try { tick(now); } catch (e) { /* 一帧出错不杀循环 */ }
|
|
295
|
+
requestAnimationFrame(frame);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
container.appendChild(host);
|
|
299
|
+
applyState(S.state);
|
|
300
|
+
requestAnimationFrame(frame);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
setState(name) {
|
|
304
|
+
applyState(name);
|
|
305
|
+
// 换状态的瞬间眨一次眼盖住形变
|
|
306
|
+
blink(0.26);
|
|
307
|
+
},
|
|
308
|
+
getState() { return S.state; },
|
|
309
|
+
blink() { blink(0.26); },
|
|
310
|
+
destroy() {
|
|
311
|
+
alive = false;
|
|
312
|
+
if (follow) {
|
|
313
|
+
window.removeEventListener('pointermove', onPointer);
|
|
314
|
+
document.removeEventListener('mouseleave', onLeave);
|
|
315
|
+
window.removeEventListener('blur', onLeave);
|
|
316
|
+
}
|
|
317
|
+
host.remove();
|
|
318
|
+
},
|
|
319
|
+
el: host,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
root.peepface = {
|
|
324
|
+
createFace,
|
|
325
|
+
STATES: Object.keys(STATES),
|
|
326
|
+
VERSION: '0.1.0',
|
|
327
|
+
};
|
|
328
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|