lively-mascot 0.2.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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * lively-mascot · Emotion Definitions
3
+ *
4
+ * Grouped by:
5
+ * - Lifecycle (00-09): Core states like idle, sleeping, waking up.
6
+ * - Reactions (10-19): Immediate responses to user actions.
7
+ * - Work States (20-31): What the bot/agent is doing (thinking, searching, etc.).
8
+ *
9
+ * Each emotion can define:
10
+ * - bodyAnim: CSS animation override for .lively-body
11
+ * - bodyFilter: CSS filter override for .lively-body
12
+ * - blink: false = force eyes closed/locked (default true)
13
+ * - gaze: false = pause cursor tracking (default true)
14
+ * - hop: true = allow interval-driven hopping for this expression
15
+ * - leafAnim: CSS animation override for .lively__leaf
16
+ * - footAnim: CSS animation override for .lively__foot
17
+ */
18
+
19
+ var LivelyEmotions = {
20
+ // ===================== LIFECYCLE (00-09) =====================
21
+ "00": { id: "00", name: "Sleep", group: "lifecycle", desc: "深度睡眠",
22
+ blink: false, gaze: false,
23
+ bodyFilter: "brightness(0.55) saturate(0.6)",
24
+ leafAnim: "lively-leaf-sleep 6s ease-in-out infinite",
25
+ footAnim: "lively-foot-rest 4s ease-in-out infinite" },
26
+ "01": { id: "01", name: "Wake", group: "lifecycle", desc: "刚睡醒",
27
+ bodyAnim: "lively-wake 1.2s ease-out",
28
+ leafAnim: "lively-leaf-wake 0.8s ease-out",
29
+ footAnim: "lively-foot-wake 0.6s ease-out" },
30
+ "02": { id: "02", name: "Idle", group: "lifecycle", desc: "待机" },
31
+ "03": { id: "03", name: "Breathe", group: "lifecycle", desc: "平稳呼吸",
32
+ bodyAnim: "lively-deep-breathe 4s ease-in-out infinite",
33
+ leafAnim: "lively-leaf-breathe 4s ease-in-out infinite",
34
+ footAnim: "lively-foot-rest 4s ease-in-out infinite" },
35
+ "04": { id: "04", name: "Ready", group: "lifecycle", desc: "随时待命",
36
+ bodyAnim: "lively-ready 2s ease-in-out infinite",
37
+ leafAnim: "lively-leaf-ready 1.5s ease-in-out infinite",
38
+ footAnim: "lively-foot-ready 1.8s ease-in-out infinite" },
39
+ "05": { id: "05", name: "Pause", group: "lifecycle", desc: "暂停中",
40
+ bodyFilter: "saturate(0.4) brightness(0.85)",
41
+ leafAnim: "lively-leaf-pause 6s ease-in-out infinite",
42
+ footAnim: "lively-foot-rest 4s ease-in-out infinite" },
43
+ "06": { id: "06", name: "Refresh", group: "lifecycle", desc: "正在刷新",
44
+ bodyAnim: "lively-refresh 0.8s ease-in-out infinite",
45
+ leafAnim: "lively-leaf-refresh 0.6s ease-in-out infinite" },
46
+ "07": { id: "07", name: "LowBattery", group: "lifecycle", desc: "电量不足",
47
+ bodyFilter: "saturate(0.3) brightness(0.7)",
48
+ bodyAnim: "lively-lowbatt 2.5s ease-in-out infinite",
49
+ leafAnim: "lively-leaf-droop 3s ease-in-out infinite",
50
+ footAnim: "lively-foot-rest 5s ease-in-out infinite" },
51
+ "08": { id: "08", name: "Offline", group: "lifecycle", desc: "已离线",
52
+ blink: false, gaze: false,
53
+ bodyFilter: "grayscale(1) brightness(0.5)",
54
+ leafAnim: "lively-leaf-droop 8s ease-in-out infinite" },
55
+ "09": { id: "09", name: "Boot", group: "lifecycle", desc: "启动中",
56
+ bodyAnim: "lively-boot 0.5s ease-in-out 3",
57
+ leafAnim: "lively-leaf-boot 0.4s ease-out 3",
58
+ footAnim: "lively-foot-boot 0.3s ease-out 3" },
59
+
60
+ // ===================== REACTIONS (10-19) =====================
61
+ "10": { id: "10", name: "Happy", group: "reaction", desc: "开心",
62
+ gaze: false,
63
+ bodyAnim: "lively-happy-bounce 0.6s ease-in-out 2",
64
+ leafAnim: "lively-leaf-happy 0.4s ease-in-out 4",
65
+ footAnim: "lively-foot-happy 0.35s ease-in-out 4" },
66
+ "11": { id: "11", name: "Excited", group: "reaction", desc: "兴奋",
67
+ gaze: false,
68
+ bodyAnim: "lively-excited 0.5s ease-in-out 3",
69
+ leafAnim: "lively-leaf-excited 0.3s ease-in-out 5",
70
+ footAnim: "lively-foot-excited 0.25s ease-in-out 6" },
71
+ "12": { id: "12", name: "Sad", group: "reaction", desc: "难过",
72
+ gaze: false,
73
+ bodyAnim: "lively-sad 3s ease-in-out infinite",
74
+ bodyFilter: "brightness(0.85) saturate(0.7)",
75
+ leafAnim: "lively-leaf-droop 2s ease-in-out infinite",
76
+ footAnim: "lively-foot-rest 4s ease-in-out infinite" },
77
+ "13": { id: "13", name: "Angry", group: "reaction", desc: "生气",
78
+ gaze: false,
79
+ bodyAnim: "lively-angry 0.15s linear infinite",
80
+ bodyFilter: "hue-rotate(340deg) saturate(1.5) brightness(0.95)",
81
+ leafAnim: "lively-leaf-angry 0.12s linear infinite",
82
+ footAnim: "lively-foot-stomp 0.18s ease-in-out infinite" },
83
+ "14": { id: "14", name: "Surprised", group: "reaction", desc: "惊讶",
84
+ gaze: false,
85
+ bodyAnim: "lively-surprise 0.5s ease-out",
86
+ leafAnim: "lively-leaf-wake 0.5s ease-out" },
87
+ "15": { id: "15", name: "Shy", group: "reaction", desc: "害羞",
88
+ gaze: false,
89
+ bodyAnim: "lively-confused 2s ease-in-out infinite",
90
+ leafAnim: "lively-leaf-shy 2s ease-in-out infinite" },
91
+ "16": { id: "16", name: "Love", group: "reaction", desc: "心动",
92
+ gaze: false,
93
+ bodyAnim: "lively-love-pulse 0.8s ease-in-out infinite",
94
+ leafAnim: "lively-leaf-happy 0.6s ease-in-out infinite" },
95
+ "17": { id: "17", name: "Confused", group: "reaction", desc: "困惑",
96
+ bodyAnim: "lively-confused 2s ease-in-out infinite",
97
+ leafAnim: "lively-leaf-confused 1.5s ease-in-out infinite" },
98
+ "18": { id: "18", name: "Cool", group: "reaction", desc: "耍酷",
99
+ bodyFilter: "contrast(1.15) brightness(1.05)",
100
+ leafAnim: "lively-leaf-ready 2s ease-in-out infinite" },
101
+ "19": { id: "19", name: "Smug", group: "reaction", desc: "得意",
102
+ bodyAnim: "lively-smug 2s ease-in-out infinite",
103
+ leafAnim: "lively-leaf-ready 1.8s ease-in-out infinite" },
104
+
105
+ // ===================== WORK STATES (20-31) =====================
106
+ "20": { id: "20", name: "Thinking", group: "work", desc: "思考中",
107
+ blink: false,
108
+ bodyAnim: "lively-thinking 2.5s ease-in-out infinite",
109
+ leafAnim: "lively-leaf-thinking 2s ease-in-out infinite" },
110
+ "21": { id: "21", name: "Listening", group: "work", desc: "倾听中",
111
+ bodyAnim: "lively-listening 2s ease-in-out infinite",
112
+ leafAnim: "lively-leaf-listen 1.8s ease-in-out infinite" },
113
+ "22": { id: "22", name: "Talking", group: "work", desc: "说话中",
114
+ bodyAnim: "lively-talking 0.5s ease-in-out infinite",
115
+ leafAnim: "lively-leaf-talking 0.8s ease-in-out infinite" },
116
+ "23": { id: "23", name: "Searching", group: "work", desc: "搜索中",
117
+ bodyAnim: "lively-searching 1.5s ease-in-out infinite",
118
+ leafAnim: "lively-leaf-searching 1.2s ease-in-out infinite",
119
+ footAnim: "lively-foot-pace 0.8s ease-in-out infinite" },
120
+ "24": { id: "24", name: "Reading", group: "work", desc: "阅读中",
121
+ bodyAnim: "lively-reading 3s ease-in-out infinite",
122
+ leafAnim: "lively-leaf-reading 2.5s ease-in-out infinite" },
123
+ "25": { id: "25", name: "Writing", group: "work", desc: "写作中",
124
+ bodyAnim: "lively-writing 1.8s ease-in-out infinite",
125
+ leafAnim: "lively-leaf-writing 1.5s ease-in-out infinite",
126
+ footAnim: "lively-foot-writing 1.2s ease-in-out infinite" },
127
+ "26": { id: "26", name: "Coding", group: "work", desc: "编程中",
128
+ blink: false,
129
+ bodyAnim: "lively-coding 2.5s ease-in-out infinite",
130
+ leafAnim: "lively-leaf-coding 2s ease-in-out infinite" },
131
+ "27": { id: "27", name: "Designing", group: "work", desc: "设计中",
132
+ bodyAnim: "lively-designing 3s ease-in-out infinite",
133
+ leafAnim: "lively-leaf-designing 2.5s ease-in-out infinite" },
134
+ "28": { id: "28", name: "Loading", group: "work", desc: "加载中",
135
+ gaze: false,
136
+ // Loading state is communicated by the rotated ring (CSS).
137
+ // Disable the base body sway so the ring stays concentric; the
138
+ // whole rig bounces instead to make the live mascot feel alive.
139
+ bodyAnim: "lively-loading-bounce 0.9s ease-in-out infinite",
140
+ footAnim: "lively-foot-rest 3s ease-in-out infinite" },
141
+ "29": { id: "29", name: "Processing", group: "work", desc: "处理中",
142
+ bodyAnim: "lively-processing 1.5s ease-in-out infinite",
143
+ leafAnim: "lively-leaf-thinking 1.2s ease-in-out infinite" },
144
+ "30": { id: "30", name: "Success", group: "work", desc: "任务完成",
145
+ gaze: false,
146
+ bodyAnim: "lively-success 0.7s ease-out",
147
+ leafAnim: "lively-leaf-happy 0.5s ease-out 2" },
148
+ "31": { id: "31", name: "Error", group: "work", desc: "发生错误",
149
+ gaze: false,
150
+ bodyFilter: "brightness(0.8) saturate(0.6)",
151
+ bodyAnim: "lively-error 0.25s linear 4",
152
+ leafAnim: "lively-leaf-error 0.2s linear 4" },
153
+
154
+ // ===================== EXTENDED (32-34) =====================
155
+ "32": { id: "32", name: "Grateful", group: "reaction", desc: "感谢",
156
+ gaze: false,
157
+ bodyAnim: "lively-love-pulse 1.2s ease-in-out infinite",
158
+ leafAnim: "lively-leaf-happy 1s ease-in-out infinite" },
159
+ "33": { id: "33", name: "Retrying", group: "work", desc: "重试中",
160
+ bodyAnim: "lively-searching 1.2s ease-in-out infinite",
161
+ leafAnim: "lively-leaf-confused 1s ease-in-out infinite",
162
+ footAnim: "lively-foot-pace 0.6s ease-in-out infinite" },
163
+ "34": { id: "34", name: "Cancelled", group: "work", desc: "已取消",
164
+ bodyFilter: "saturate(0.5) brightness(0.8)",
165
+ leafAnim: "lively-leaf-droop 3s ease-in-out infinite",
166
+ footAnim: "lively-foot-rest 3s ease-in-out infinite" },
167
+ "35": { id: "35", name: "Crying", group: "reaction", desc: "哭泣",
168
+ gaze: false,
169
+ bodyFilter: "brightness(0.9) saturate(0.8)",
170
+ bodyAnim: "lively-sad 2s ease-in-out infinite",
171
+ leafAnim: "lively-leaf-droop 1.5s ease-in-out infinite",
172
+ footAnim: "lively-foot-rest 3s ease-in-out infinite" },
173
+ "36": { id: "36", name: "Bored", group: "reaction", desc: "无聊",
174
+ bodyAnim: "lively-bored 4s ease-in-out infinite",
175
+ leafAnim: "lively-leaf-droop 5s ease-in-out infinite",
176
+ footAnim: "lively-foot-rest 4s ease-in-out infinite" },
177
+ "37": { id: "37", name: "Nervous", group: "reaction", desc: "紧张",
178
+ bodyAnim: "lively-nervous 0.8s ease-in-out infinite",
179
+ leafAnim: "lively-leaf-nervous 0.6s ease-in-out infinite",
180
+ footAnim: "lively-foot-nervous 0.3s ease-in-out infinite" },
181
+ "38": { id: "38", name: "Eureka", group: "reaction", desc: "灵光一现",
182
+ gaze: false,
183
+ bodyAnim: "lively-eureka 0.6s ease-out",
184
+ leafAnim: "lively-leaf-eureka 0.4s ease-out 2",
185
+ footAnim: "lively-foot-happy 0.3s ease-out 3" },
186
+ "39": { id: "39", name: "Waiting", group: "work", desc: "等待中",
187
+ bodyAnim: "lively-waiting 2s ease-in-out infinite",
188
+ leafAnim: "lively-leaf-waiting 1.5s ease-in-out infinite",
189
+ footAnim: "lively-foot-tap 0.5s ease-in-out infinite" }
190
+ };
191
+
192
+ var LivelyEmotionGroups = {
193
+ "lifecycle": { name: "生命周期", order: 0 },
194
+ "reaction": { name: "情绪反应", order: 1 },
195
+ "work": { name: "工作状态", order: 2 }
196
+ };
197
+
198
+ // Keep the no-build browser global while making the data consumable from
199
+ // Node/CommonJS when the source SDK is used as an npm entry point.
200
+ if (typeof module === "object" && module.exports) {
201
+ module.exports = { emotions: LivelyEmotions, groups: LivelyEmotionGroups };
202
+ }
203
+ if (typeof globalThis !== "undefined") {
204
+ globalThis.LivelyEmotions = LivelyEmotions;
205
+ globalThis.LivelyEmotionGroups = LivelyEmotionGroups;
206
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * lively-mascot · Core Animation Rig
3
+ *
4
+ * Handles gaze tracking, blinking, hopping, and emotion state management.
5
+ * This is the "engine" that drives the mascot's movement.
6
+ *
7
+ * Requires: src/core/emotions.js (loaded before this script)
8
+ * Globals: LivelyEmotions (from emotions.js), clamp (defined below)
9
+ */
10
+
11
+ // --- Helpers ---
12
+ var clamp = function (v, min, max) { return v < min ? min : v > max ? max : v; };
13
+
14
+ // --- Core Rig ---
15
+ function createRig(root, rigEl, config, handlers) {
16
+ config = config || {};
17
+ handlers = handlers || {};
18
+ var animated = config.animated !== false;
19
+ var followCursor = config.followCursor !== false;
20
+ var gazeRadiusX = config.gazeRadiusX || 200;
21
+ var gazeRadiusY = config.gazeRadiusY || 160;
22
+ var currentHopInterval = config.hopInterval === undefined ? [6, 13] : config.hopInterval;
23
+ var pupils = [];
24
+ var eyes = [];
25
+ var face = null;
26
+ var happy = false;
27
+ var hopping = false;
28
+ var following = animated && followCursor;
29
+ var currentEmotionId = "02"; // Default idle
30
+ var bodyEl = null;
31
+ var leafEl = null;
32
+ var leafUseLeafAnim = true;
33
+ var feetEl = null;
34
+ var gazeEl = null;
35
+ var faceAccessories = {};
36
+ var activeFaceAccessory = null;
37
+
38
+ var api = {
39
+ registerPupil: function (elm, spec) {
40
+ for (var i = 0; i < pupils.length; i++) { if (pupils[i].el === elm) { pupils[i].spec = spec; return; } }
41
+ pupils.push({ el: elm, spec: spec || { maxX: 8, maxY: 6 } });
42
+ },
43
+ registerEye: function (elm) { if (eyes.indexOf(elm) === -1) eyes.push(elm); },
44
+ registerFace: function (elm) { face = elm; },
45
+ registerBody: function (elm) { bodyEl = elm; },
46
+ registerLeaf: function (elm, opts) {
47
+ leafEl = elm;
48
+ // sprout uses its emotion `leafAnim` data; cat drives ears via its own CSS.
49
+ leafUseLeafAnim = !(opts && opts.useLeafAnim === false);
50
+ },
51
+ registerFeet: function (elm) { feetEl = elm; },
52
+ registerGazeWrap: function (elm) { gazeEl = elm; },
53
+ registerFaceAccessory: function (name, elm) {
54
+ if (!name || !elm) return;
55
+ faceAccessories[String(name)] = elm;
56
+ elm.classList.add("lively-face-accessory");
57
+ if (activeFaceAccessory === null) activeFaceAccessory = String(name);
58
+ elm.classList.toggle("is-active", activeFaceAccessory === String(name));
59
+ },
60
+ setFaceAccessory: function (name) {
61
+ var key = name == null ? null : String(name);
62
+ activeFaceAccessory = key;
63
+ for (var accessoryName in faceAccessories) {
64
+ if (Object.prototype.hasOwnProperty.call(faceAccessories, accessoryName)) {
65
+ faceAccessories[accessoryName].classList.toggle("is-active", accessoryName === key);
66
+ }
67
+ }
68
+ },
69
+ setEmotionState: function (id) {
70
+ currentEmotionId = String(id);
71
+ applyEmotionBehavior(currentEmotionId);
72
+ syncHop();
73
+ }
74
+ };
75
+
76
+ function setHappy(v) { if (happy === v) return; happy = v; root.classList.toggle("is-happy", v); }
77
+ function setHopping(v) { if (hopping === v) return; hopping = v; root.classList.toggle("is-hopping", v); }
78
+
79
+ // --- Emotion Behavior ---
80
+ function getEmotionDef(id) { return LivelyEmotions[String(id)]; }
81
+
82
+ function clearEmotionBehavior() {
83
+ if (bodyEl) { bodyEl.style.animation = ""; bodyEl.style.filter = ""; }
84
+ rigEl.style.animation = "";
85
+ if (leafEl && leafUseLeafAnim) { leafEl.style.animation = ""; }
86
+ if (feetEl) {
87
+ feetEl.style.removeProperty("--lively-state-filter");
88
+ var footL = feetEl.querySelector(".lively__foot--l");
89
+ var footR = feetEl.querySelector(".lively__foot--r");
90
+ if (footL) footL.style.animation = "";
91
+ if (footR) footR.style.animation = "";
92
+ }
93
+ }
94
+
95
+ function applyEmotionBehavior(id) {
96
+ var def = getEmotionDef(id);
97
+ if (!def) { clearEmotionBehavior(); return; }
98
+ // Static mode: the visible face is driven by the `is-emotion-XX` class
99
+ // (set on the root by the SDK), so we keep that but apply no motion —
100
+ // no body/leaf/foot animation and no filter flicker.
101
+ if (!animated) {
102
+ if (bodyEl) bodyEl.style.filter = def.bodyFilter || "";
103
+ if (feetEl) feetEl.style.setProperty("--lively-state-filter", def.bodyFilter || "brightness(1)");
104
+ return;
105
+ }
106
+ // Body
107
+ if (bodyEl) {
108
+ bodyEl.style.animation = def.bodyAnim || "";
109
+ bodyEl.style.filter = def.bodyFilter || "";
110
+ }
111
+ // Leaf (top decoration). Only drive it from emotion data when the
112
+ // character opts in (sprout). Cat drives its ears via its own CSS rules.
113
+ if (leafEl && leafUseLeafAnim) {
114
+ leafEl.style.animation = def.leafAnim || "";
115
+ }
116
+ // Feet
117
+ if (feetEl) {
118
+ feetEl.style.setProperty("--lively-state-filter", def.bodyFilter || "brightness(1)");
119
+ var footL = feetEl.querySelector(".lively__foot--l");
120
+ var footR = feetEl.querySelector(".lively__foot--r");
121
+ if (footL) footL.style.animation = def.footAnim || "";
122
+ if (footR) footR.style.animation = def.footAnim || "";
123
+ }
124
+ // Refresh (06) + Loading (28): drive the entire rig (whole mascot
125
+ // bounces / rotates with feet attached) instead of just the body.
126
+ if (id === "06" || id === "28") {
127
+ if (bodyEl) bodyEl.style.animation = "";
128
+ rigEl.style.animation = def.bodyAnim || "";
129
+ } else {
130
+ // Everything else: keep the rig stationary so overlays line up.
131
+ rigEl.style.animation = "";
132
+ }
133
+ }
134
+
135
+ // --- Gaze ---
136
+ var raf = 0, rect = root.getBoundingClientRect(), rectAt = performance.now();
137
+ var targetX = 0, targetY = 0, curX = 0, curY = 0;
138
+
139
+ function shouldGaze() {
140
+ // Explicit gaze:false emotions
141
+ var def = getEmotionDef(currentEmotionId);
142
+ if (def && def.gaze === false) return false;
143
+ // All active emotions (non-idle) pause gaze following
144
+ if (currentEmotionId !== "02") return false;
145
+ return following;
146
+ }
147
+
148
+ function onPointerMove(e) {
149
+ if (!shouldGaze()) return;
150
+ var now = performance.now();
151
+ if (now - rectAt > 250) { rect = root.getBoundingClientRect(); rectAt = now; }
152
+ var cx = rect.left + rect.width / 2;
153
+ var cy = rect.top + rect.height / 2;
154
+ targetX = clamp((e.clientX - cx) / gazeRadiusX, -1, 1);
155
+ targetY = clamp((e.clientY - cy) / gazeRadiusY, -1, 1);
156
+ }
157
+
158
+ function tick() {
159
+ if (!shouldGaze()) { targetX = 0; targetY = 0; }
160
+ curX += (targetX - curX) * 0.2;
161
+ curY += (targetY - curY) * 0.2;
162
+ for (var i = 0; i < pupils.length; i++) {
163
+ var p = pupils[i];
164
+ p.el.setAttribute("transform", "translate(" + (curX * p.spec.maxX).toFixed(2) + " " + (curY * p.spec.maxY).toFixed(2) + ")");
165
+ }
166
+ if (face) face.setAttribute("transform", "rotate(" + (curX * 3).toFixed(2) + " 50 52)");
167
+ var postureEl = gazeEl || rigEl;
168
+ if (root.classList.contains("lively-mascot--3d")) {
169
+ // Keep the character's parts in one shared 3D posture layer. Pointer
170
+ // position steers the turn, while active expressions retain a subtle
171
+ // living tilt after gaze tracking has intentionally paused.
172
+ var emotionPhase = performance.now() / 720 + Number(currentEmotionId || 0) * 0.67;
173
+ var emotionPitch = currentEmotionId === "02" ? 0 : Math.sin(emotionPhase) * 1.2;
174
+ var emotionYaw = currentEmotionId === "02" ? 0 : Math.cos(emotionPhase * 0.82) * 1.7;
175
+ var pitch = 3.2 - curY * 7.2 + emotionPitch;
176
+ var yaw = -3.2 + curX * 9.4 + emotionYaw;
177
+ // A flat SVG silhouette naturally narrows at a camera angle. Gently
178
+ // compensate that projection so the mascot keeps its rounded volume.
179
+ var volumeX = 1 + Math.abs(yaw) * 0.0027;
180
+ var volumeY = 1 + Math.abs(pitch) * 0.0022;
181
+ root.style.setProperty("--lively-gloss-x", (25 + curX * 15).toFixed(1) + "%");
182
+ root.style.setProperty("--lively-gloss-y", (18 + curY * 12).toFixed(1) + "%");
183
+ postureEl.style.transform = "translate3d(" + (curX * 2.2).toFixed(1) + "px, " + (curY * 1.4).toFixed(1) + "px, 3px) scale3d(" + volumeX.toFixed(3) + ", " + volumeY.toFixed(3) + ", 1) rotateZ(" + (curX * 1.8).toFixed(2) + "deg) rotateX(" + pitch.toFixed(2) + "deg) rotateY(" + yaw.toFixed(2) + "deg)";
184
+ } else {
185
+ postureEl.style.transform = "rotate(" + (curX * 5).toFixed(2) + "deg) translate3d(" + (curX * 4).toFixed(1) + "px, " + (curY * 2.5).toFixed(1) + "px, 0)";
186
+ }
187
+ raf = requestAnimationFrame(tick);
188
+ }
189
+
190
+ // --- Blink ---
191
+ var blinkTimer = 0, phaseTimer = 0;
192
+ function scheduleBlink() {
193
+ var def = getEmotionDef(currentEmotionId);
194
+ // No blink if disabled
195
+ if (def && def.blink === false) {
196
+ blinkTimer = window.setTimeout(scheduleBlink, 300);
197
+ return;
198
+ }
199
+ var blinkDelay = def && def.blink === "fast" ? 800 + Math.random() * 1200 : 2200 + Math.random() * 2600;
200
+ blinkTimer = window.setTimeout(function () {
201
+ for (var i = 0; i < eyes.length; i++) eyes[i].classList.add("is-blinking");
202
+ phaseTimer = window.setTimeout(function () {
203
+ for (var i = 0; i < eyes.length; i++) eyes[i].classList.remove("is-blinking");
204
+ if (Math.random() < 0.18) {
205
+ phaseTimer = window.setTimeout(function () {
206
+ for (var i = 0; i < eyes.length; i++) eyes[i].classList.add("is-blinking");
207
+ phaseTimer = window.setTimeout(function () {
208
+ for (var i = 0; i < eyes.length; i++) eyes[i].classList.remove("is-blinking");
209
+ scheduleBlink();
210
+ }, 90);
211
+ }, 160);
212
+ } else scheduleBlink();
213
+ }, 110);
214
+ }, blinkDelay);
215
+ }
216
+
217
+ // --- Hop ---
218
+ var hopTimer = 0, hopResetTimer = 0;
219
+
220
+ // A hop is an expression-specific action, never a background interruption.
221
+ function canHop() {
222
+ var def = getEmotionDef(currentEmotionId);
223
+ return animated && !!currentHopInterval && !!(def && def.hop);
224
+ }
225
+
226
+ function stopHop() {
227
+ clearTimeout(hopTimer);
228
+ clearTimeout(hopResetTimer);
229
+ hopTimer = 0;
230
+ hopResetTimer = 0;
231
+ setHopping(false);
232
+ }
233
+
234
+ function scheduleHop() {
235
+ if (!canHop()) return;
236
+ hopTimer = window.setTimeout(function () {
237
+ if (!canHop()) return;
238
+ setHopping(true);
239
+ hopResetTimer = window.setTimeout(function () {
240
+ setHopping(false);
241
+ scheduleHop();
242
+ }, 900);
243
+ }, currentHopInterval[0] * 1000 + Math.random() * (currentHopInterval[1] - currentHopInterval[0]) * 1000);
244
+ }
245
+
246
+ function syncHop() {
247
+ stopHop();
248
+ scheduleHop();
249
+ }
250
+
251
+ function fireClick() {
252
+ if (handlers.onClick) handlers.onClick();
253
+ // Skip happy flash if a non-idle emotion is active (avoid face overlap)
254
+ if (currentEmotionId !== "02") return;
255
+ setHappy(true);
256
+ setTimeout(function(){ setHappy(false); }, 950);
257
+ }
258
+
259
+ if (following) { raf = requestAnimationFrame(tick); window.addEventListener("pointermove", onPointerMove, { passive: true }); }
260
+ if (animated) {
261
+ scheduleBlink();
262
+ syncHop();
263
+ root.addEventListener("pointerdown", fireClick);
264
+ }
265
+
266
+ return {
267
+ api: api,
268
+ click: fireClick,
269
+ setFollowCursor: function(e){ following=e; },
270
+ destroy: function() {
271
+ cancelAnimationFrame(raf);
272
+ window.removeEventListener("pointermove", onPointerMove);
273
+ clearTimeout(blinkTimer); clearTimeout(phaseTimer);
274
+ clearTimeout(hopTimer); clearTimeout(hopResetTimer);
275
+ }
276
+ };
277
+ }
278
+
279
+ // The browser build still uses the top-level function. Exporting it here
280
+ // also lets the CommonJS SDK entry assemble the same runtime in Node.
281
+ if (typeof module === "object" && module.exports) module.exports = { createRig: createRig };
282
+ if (typeof globalThis !== "undefined") globalThis.createRig = createRig;