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/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);