bloby-bot 0.50.3 → 0.51.2

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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.50.3",
3
+ "version": "0.51.2",
4
4
  "releaseNotes": [
5
- "1. Something great..",
6
- "2. ",
7
- "3. ",
5
+ "1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
6
+ "2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
7
+ "3. SW cache bumped to v16 to clear old assets",
8
8
  "4. "
9
9
  ],
10
10
  "description": "Self-hosted, self-evolving AI agent with its own dashboard.",
@@ -1,26 +1,40 @@
1
1
  import { useEffect, useRef, useCallback } from 'react';
2
2
 
3
- // v2 sprite config
4
- const HP_COLS = 16;
5
- const HP_FRAME_W = 126;
6
- const HP_FRAME_H = 84;
7
- const HP_IDLE_FRAME = 10;
8
- const HP_ACTIVATE_START = 11;
9
- const HP_ACTIVATE_END = 40;
10
- const HP_RECORD_START = 40;
11
- const HP_RECORD_END = 85;
12
- const HP_BASE_FPS = 24;
13
- const HP_FAST_FPS = 48;
3
+ // Config-driven. Loaded from /morphy/headphones.json.
4
+ // Clips map to states:
5
+ // idle (hold) → 'idle'
6
+ // enter (forward) → 'activating'
7
+ // active (pingpong)→ 'recording'
8
+ // exit (forward) → 'deactivating'
9
+ const HP_CONFIG_URL = '/morphy/headphones.json';
10
+ const HP_ASSETS_DIR = '/morphy/';
11
+
12
+ interface HpConfig {
13
+ spritesheet: string;
14
+ frame: { w: number; h: number };
15
+ grid: { cols: number; rows: number };
16
+ fps: number;
17
+ clips: {
18
+ idle: { from: number; to: number; mode: string; fps?: number };
19
+ enter: { from: number; to: number; mode: string; fps?: number };
20
+ active: { from: number; to: number; mode: string; fps?: number };
21
+ exit: { from: number; to: number; mode: string; fps?: number };
22
+ };
23
+ }
14
24
 
15
25
  let cachedSprite: HTMLImageElement | null = null;
16
- function getSprite(): Promise<HTMLImageElement> {
17
- if (cachedSprite) return Promise.resolve(cachedSprite);
18
- return new Promise((resolve, reject) => {
19
- const img = new Image();
20
- img.onload = () => { cachedSprite = img; resolve(img); };
21
- img.onerror = reject;
22
- img.src = '/headphones_spritesheet.webp';
23
- });
26
+ let cachedConfig: HpConfig | null = null;
27
+
28
+ function loadConfigAndSprite(): Promise<{ img: HTMLImageElement; cfg: HpConfig }> {
29
+ if (cachedSprite && cachedConfig) return Promise.resolve({ img: cachedSprite, cfg: cachedConfig });
30
+ return fetch(HP_CONFIG_URL)
31
+ .then((r) => { if (!r.ok) throw new Error('hp config ' + r.status); return r.json() as Promise<HpConfig>; })
32
+ .then((cfg) => new Promise<{ img: HTMLImageElement; cfg: HpConfig }>((resolve, reject) => {
33
+ const img = new Image();
34
+ img.onload = () => { cachedSprite = img; cachedConfig = cfg; resolve({ img, cfg }); };
35
+ img.onerror = reject;
36
+ img.src = HP_ASSETS_DIR + cfg.spritesheet;
37
+ }));
24
38
  }
25
39
 
26
40
  interface Props {
@@ -34,8 +48,9 @@ type HpState = 'idle' | 'activating' | 'activating_then_deactivate' | 'recording
34
48
  export default function HeadphonesAnimation({ recording, height = 36, onDone }: Props) {
35
49
  const canvasRef = useRef<HTMLCanvasElement>(null);
36
50
  const spriteRef = useRef<HTMLImageElement | null>(cachedSprite);
51
+ const configRef = useRef<HpConfig | null>(cachedConfig);
37
52
  const stateRef = useRef<HpState>('idle');
38
- const frameRef = useRef(HP_ACTIVATE_START);
53
+ const frameRef = useRef(0);
39
54
  const pingPongRef = useRef(1);
40
55
  const lastRef = useRef(0);
41
56
  const rafRef = useRef(0);
@@ -43,16 +58,28 @@ export default function HeadphonesAnimation({ recording, height = 36, onDone }:
43
58
  const onDoneRef = useRef(onDone);
44
59
  onDoneRef.current = onDone;
45
60
 
61
+ // Aspect ratio from config (defaults to 1:1 — matches new sprite).
62
+ const cfg = configRef.current;
63
+ const aspectW = cfg ? cfg.frame.w : 180;
64
+ const aspectH = cfg ? cfg.frame.h : 180;
46
65
  const displayH = height;
47
- const displayW = Math.round(displayH * (HP_FRAME_W / HP_FRAME_H));
66
+ const displayW = Math.round(displayH * (aspectW / aspectH));
48
67
 
49
- useEffect(() => { getSprite().then((img) => { spriteRef.current = img; }); }, []);
68
+ useEffect(() => {
69
+ loadConfigAndSprite().then(({ img, cfg }) => {
70
+ spriteRef.current = img;
71
+ configRef.current = cfg;
72
+ if (stateRef.current === 'idle') frameRef.current = cfg.clips.idle.from;
73
+ });
74
+ }, []);
50
75
 
51
76
  // React to recording prop
52
77
  useEffect(() => {
78
+ const cfg = configRef.current;
79
+ if (!cfg) return;
53
80
  if (recording && !prevRecording.current) {
54
81
  stateRef.current = 'activating';
55
- frameRef.current = HP_ACTIVATE_START;
82
+ frameRef.current = cfg.clips.enter.from;
56
83
  pingPongRef.current = 1;
57
84
  lastRef.current = performance.now();
58
85
  } else if (!recording && prevRecording.current) {
@@ -60,7 +87,7 @@ export default function HeadphonesAnimation({ recording, height = 36, onDone }:
60
87
  stateRef.current = 'activating_then_deactivate';
61
88
  } else if (stateRef.current === 'recording') {
62
89
  stateRef.current = 'deactivating';
63
- frameRef.current = HP_ACTIVATE_END;
90
+ frameRef.current = cfg.clips.exit.from;
64
91
  lastRef.current = performance.now();
65
92
  }
66
93
  }
@@ -70,17 +97,24 @@ export default function HeadphonesAnimation({ recording, height = 36, onDone }:
70
97
  const tick = useCallback((now: number) => {
71
98
  const canvas = canvasRef.current;
72
99
  const sprite = spriteRef.current;
100
+ const cfg = configRef.current;
73
101
  if (!canvas) { rafRef.current = requestAnimationFrame(tick); return; }
74
102
  const ctx = canvas.getContext('2d')!;
75
103
  const state = stateRef.current;
76
104
 
77
- if (state === 'idle' || !sprite) {
105
+ if (state === 'idle' || !sprite || !cfg) {
78
106
  ctx.clearRect(0, 0, displayW, displayH);
79
107
  rafRef.current = requestAnimationFrame(tick);
80
108
  return;
81
109
  }
82
110
 
83
- const fps = (state === 'activating' || state === 'activating_then_deactivate' || state === 'deactivating') ? HP_FAST_FPS : HP_BASE_FPS;
111
+ const baseFps = cfg.fps;
112
+ const fps =
113
+ state === 'activating' || state === 'activating_then_deactivate'
114
+ ? (cfg.clips.enter.fps ?? baseFps)
115
+ : state === 'deactivating'
116
+ ? (cfg.clips.exit.fps ?? baseFps)
117
+ : (cfg.clips.active.fps ?? baseFps);
84
118
  const frameMs = 1000 / fps;
85
119
  const delta = now - lastRef.current;
86
120
  if (delta >= frameMs) {
@@ -88,24 +122,25 @@ export default function HeadphonesAnimation({ recording, height = 36, onDone }:
88
122
 
89
123
  if (state === 'activating' || state === 'activating_then_deactivate') {
90
124
  frameRef.current++;
91
- if (frameRef.current > HP_ACTIVATE_END) {
125
+ if (frameRef.current > cfg.clips.enter.to) {
92
126
  if (state === 'activating_then_deactivate') {
93
127
  stateRef.current = 'deactivating';
94
- frameRef.current = HP_ACTIVATE_END;
128
+ frameRef.current = cfg.clips.exit.from;
95
129
  } else {
96
130
  stateRef.current = 'recording';
97
- frameRef.current = HP_RECORD_START;
131
+ frameRef.current = cfg.clips.active.from;
98
132
  pingPongRef.current = 1;
99
133
  }
100
134
  }
101
135
  } else if (state === 'recording') {
102
136
  frameRef.current += pingPongRef.current;
103
- if (frameRef.current >= HP_RECORD_END) { frameRef.current = HP_RECORD_END; pingPongRef.current = -1; }
104
- else if (frameRef.current <= HP_RECORD_START) { frameRef.current = HP_RECORD_START; pingPongRef.current = 1; }
137
+ if (frameRef.current >= cfg.clips.active.to) { frameRef.current = cfg.clips.active.to; pingPongRef.current = -1; }
138
+ else if (frameRef.current <= cfg.clips.active.from) { frameRef.current = cfg.clips.active.from; pingPongRef.current = 1; }
105
139
  } else if (state === 'deactivating') {
106
- frameRef.current--;
107
- if (frameRef.current < HP_ACTIVATE_START) {
140
+ frameRef.current++;
141
+ if (frameRef.current > cfg.clips.exit.to) {
108
142
  stateRef.current = 'idle';
143
+ frameRef.current = cfg.clips.idle.from;
109
144
  ctx.clearRect(0, 0, displayW, displayH);
110
145
  onDoneRef.current?.();
111
146
  rafRef.current = requestAnimationFrame(tick);
@@ -114,12 +149,15 @@ export default function HeadphonesAnimation({ recording, height = 36, onDone }:
114
149
  }
115
150
  }
116
151
 
117
- const col = frameRef.current % HP_COLS;
118
- const row = Math.floor(frameRef.current / HP_COLS);
152
+ const cols = cfg.grid.cols;
153
+ const fw = cfg.frame.w;
154
+ const fh = cfg.frame.h;
155
+ const col = frameRef.current % cols;
156
+ const row = Math.floor(frameRef.current / cols);
119
157
  ctx.clearRect(0, 0, displayW, displayH);
120
158
  ctx.imageSmoothingEnabled = true;
121
159
  ctx.imageSmoothingQuality = 'high';
122
- ctx.drawImage(sprite, col * HP_FRAME_W, row * HP_FRAME_H, HP_FRAME_W, HP_FRAME_H, 0, 0, displayW, displayH);
160
+ ctx.drawImage(sprite, col * fw, row * fh, fw, fh, 0, 0, displayW, displayH);
123
161
 
124
162
  rafRef.current = requestAnimationFrame(tick);
125
163
  }, [displayW, displayH]);
@@ -46,6 +46,11 @@ const PLATFORM_ASSETS = new Set([
46
46
  '/manifest.json',
47
47
  ]);
48
48
 
49
+ // Directory-prefix platform assets — anything under these is served from supervisor/public/.
50
+ // Used for the Morphy animation set: drop new {clip}.png + {clip}.json into public/morphy/
51
+ // and they're automatically served without touching the allowlist.
52
+ const PLATFORM_ASSET_DIRS = ['/morphy/'];
53
+
49
54
  // Ensure dist-bloby exists (postinstall may have failed silently)
50
55
  if (!fs.existsSync(DIST_BLOBY)) {
51
56
  log.info('Building bloby chat UI (first run)...');
@@ -85,7 +90,7 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
85
90
  // JS/CSS modules → stale-while-revalidate
86
91
  // API, WebSocket, Vite internals → network-only (no cache)
87
92
 
88
- var CACHE = 'bloby-v14';
93
+ var CACHE = 'bloby-v16';
89
94
  var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
90
95
 
91
96
  // Precache the HTML shell on install so the cache is never empty.
@@ -1631,7 +1636,8 @@ mint();
1631
1636
 
1632
1637
  // Platform assets — served from supervisor/public/ so they survive workspace swaps
1633
1638
  const cleanUrl = (req.url || '').split('?')[0];
1634
- if (PLATFORM_ASSETS.has(cleanUrl)) {
1639
+ const inAssetDir = PLATFORM_ASSET_DIRS.some((d) => cleanUrl.startsWith(d) && !cleanUrl.includes('..'));
1640
+ if (PLATFORM_ASSETS.has(cleanUrl) || inAssetDir) {
1635
1641
  const assetPath = path.join(SUPERVISOR_PUBLIC, cleanUrl);
1636
1642
  try {
1637
1643
  const stat = fs.statSync(assetPath);
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "headphones",
3
+ "spritesheet": "headphones.png",
4
+ "frame": {
5
+ "w": 174,
6
+ "h": 180
7
+ },
8
+ "grid": {
9
+ "cols": 16,
10
+ "rows": 8
11
+ },
12
+ "totalFrames": 121,
13
+ "fps": 24,
14
+ "clips": {
15
+ "idle": {
16
+ "from": 15,
17
+ "to": 15,
18
+ "mode": "hold",
19
+ "fps": 38
20
+ },
21
+ "enter": {
22
+ "from": 16,
23
+ "to": 25,
24
+ "mode": "forward",
25
+ "fps": 48,
26
+ "next": "active"
27
+ },
28
+ "active": {
29
+ "from": 26,
30
+ "to": 94,
31
+ "mode": "pingpong"
32
+ },
33
+ "exit": {
34
+ "from": 96,
35
+ "to": 105,
36
+ "mode": "forward",
37
+ "next": "idle"
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "teleporting",
3
+ "spritesheet": "teleporting.png",
4
+ "frame": {
5
+ "w": 218,
6
+ "h": 180
7
+ },
8
+ "grid": {
9
+ "cols": 16,
10
+ "rows": 8
11
+ },
12
+ "totalFrames": 121,
13
+ "fps": 24,
14
+ "clips": {
15
+ "idle": {
16
+ "from": 19,
17
+ "to": 19,
18
+ "mode": "hold"
19
+ },
20
+ "enter": {
21
+ "from": 20,
22
+ "to": 45,
23
+ "mode": "forward",
24
+ "next": "active"
25
+ },
26
+ "active": {
27
+ "from": 46,
28
+ "to": 68,
29
+ "mode": "pingpong"
30
+ },
31
+ "exit": {
32
+ "from": 69,
33
+ "to": 121,
34
+ "mode": "forward",
35
+ "next": "idle"
36
+ }
37
+ }
38
+ }
@@ -33,24 +33,34 @@
33
33
 
34
34
  // ══════════════════════════════════════════════════════════════════
35
35
  // ── Blob Sprite Sheet Config (splash only) ───────────────────────
36
+ // Loaded from /morphy/teleporting.json. Clips map to animStates:
37
+ // idle (hold) → animState 'idle'
38
+ // enter (forward) → animState 'melting'
39
+ // active (pingpong)→ animState 'traveling'
40
+ // exit (forward) → animState 'reforming'
36
41
  // ══════════════════════════════════════════════════════════════════
37
42
 
43
+ var BLOB_CONFIG_URL = '/morphy/teleporting.json';
44
+ var BLOB_ASSETS_DIR = '/morphy/';
45
+
46
+ // Defaults mirror the teleporting.json shipped at the URL above so
47
+ // animation works even if the fetch is cached at the wrong revision.
38
48
  var COLS = 16;
39
- var FRAME_W = 125;
40
- var FRAME_H = 120;
49
+ var FRAME_W = 218;
50
+ var FRAME_H = 180;
41
51
  var DISPLAY_H = 58;
42
52
  var DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
43
53
 
44
- var IDLE_START = 0, IDLE_END = 29;
45
- var MELT_START = 30, MELT_END = 52;
46
- var TRAVEL_START = 52, TRAVEL_END = 84;
47
- var REFORM_START = 84, REFORM_END = 191;
54
+ var IDLE_START = 19, IDLE_END = 19;
55
+ var MELT_START = 20, MELT_END = 45;
56
+ var TRAVEL_START = 46, TRAVEL_END = 68;
57
+ var REFORM_START = 69, REFORM_END = 121;
48
58
 
49
- var FPS = 29;
59
+ var FPS = 24;
50
60
  var FRAME_MS = 1000 / FPS;
51
- var IDLE_FPS = 22;
61
+ var IDLE_FPS = 24;
52
62
  var IDLE_FRAME_MS = 1000 / IDLE_FPS;
53
- var REFORM_FPS = 70;
63
+ var REFORM_FPS = 24;
54
64
  var REFORM_FRAME_MS = 1000 / REFORM_FPS;
55
65
 
56
66
  var TRAVEL_PX_PER_MS = 0.65;
@@ -67,24 +77,31 @@
67
77
  document.body.appendChild(badgeEl);
68
78
 
69
79
  // ══════════════════════════════════════════════════════════════════
70
- // ── Headphones Sprite Sheet Config (v2 bubble + recording) ─────
80
+ // ── Headphones Sprite Sheet Config (loaded from JSON) ────────────
81
+ // Clips map to hpStates:
82
+ // idle (hold) → hpState 'idle'
83
+ // enter (forward) → hpState 'activating'
84
+ // active (pingpong)→ hpState 'recording'
85
+ // exit (forward) → hpState 'deactivating'
71
86
  // ══════════════════════════════════════════════════════════════════
72
87
 
88
+ var HP_CONFIG_URL = '/morphy/headphones.json';
89
+
90
+ // Defaults mirror the shipped JSON so animation works even if fetch is cached.
73
91
  var HP_COLS = 16;
74
- var HP_FRAME_W = 126;
75
- var HP_FRAME_H = 84;
76
- // Bubble display: fill 60px width exactly
92
+ var HP_FRAME_W = 180;
93
+ var HP_FRAME_H = 180;
77
94
  var HP_BUBBLE_H = 40;
78
- var HP_BUBBLE_W = HP_BUBBLE_H * (HP_FRAME_W / HP_FRAME_H); // 60
95
+ var HP_BUBBLE_W = HP_BUBBLE_H * (HP_FRAME_W / HP_FRAME_H);
79
96
 
80
- // v2 frame ranges
81
- var HP_IDLE_FRAME = 10;
82
- var HP_ACTIVATE_START = 11, HP_ACTIVATE_END = 40;
83
- var HP_RECORD_START = 40, HP_RECORD_END = 85;
97
+ var HP_IDLE_FRAME = 15;
98
+ var HP_ACTIVATE_START = 16, HP_ACTIVATE_END = 25;
99
+ var HP_RECORD_START = 26, HP_RECORD_END = 94;
100
+ var HP_EXIT_START = 96, HP_EXIT_END = 105;
84
101
 
85
- // v2 FPS: 2x speed for activate/deactivate, normal for recording
86
- var HP_BASE_FPS = 24;
87
- var HP_FAST_FPS = 48;
102
+ var HP_BASE_FPS = 24; // active / record default
103
+ var HP_ACTIVATE_FPS = 48; // enter clip fps
104
+ var HP_EXIT_FPS = 24; // exit clip fps
88
105
 
89
106
  // Long-press threshold
90
107
  var HP_HOLD_MS = 500;
@@ -147,6 +164,7 @@
147
164
  var animState = 'loading';
148
165
  var currentFrame = 0;
149
166
  var idleDirection = 1;
167
+ var travelFrameDir = 1;
150
168
  var lastFrameTime = 0;
151
169
  var travelDuration = 0;
152
170
  var travelStartTime = 0;
@@ -181,30 +199,70 @@
181
199
  var hpSpeechInstance = null;
182
200
  var hpSpeechTranscript = '';
183
201
 
184
- // ── Load blob sprite sheet ──
202
+ // ── Load blob sprite sheet (config + image) ──
203
+ function applyBlobConfig(cfg) {
204
+ COLS = cfg.grid.cols;
205
+ FRAME_W = cfg.frame.w;
206
+ FRAME_H = cfg.frame.h;
207
+ DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
208
+ IDLE_START = cfg.clips.idle.from; IDLE_END = cfg.clips.idle.to;
209
+ MELT_START = cfg.clips.enter.from; MELT_END = cfg.clips.enter.to;
210
+ TRAVEL_START = cfg.clips.active.from; TRAVEL_END = cfg.clips.active.to;
211
+ REFORM_START = cfg.clips.exit.from; REFORM_END = cfg.clips.exit.to;
212
+ FPS = cfg.fps; FRAME_MS = 1000 / FPS;
213
+ IDLE_FPS = cfg.fps; IDLE_FRAME_MS = 1000 / IDLE_FPS;
214
+ REFORM_FPS = cfg.fps; REFORM_FRAME_MS = 1000 / REFORM_FPS;
215
+ }
216
+
185
217
  function loadSprite(onDone, onFail) {
186
- var img = new Image();
187
- img.onload = function () {
188
- spriteSheet = img;
189
- center.x = Math.round(W / 2);
190
- center.y = Math.round(H / 2);
191
- currentFrame = IDLE_START;
192
- animState = 'idle';
193
- var splash = document.getElementById('splash');
194
- if (splash) splash.style.display = 'none';
195
- onDone();
196
- };
197
- img.onerror = function () { if (onFail) onFail(); };
198
- img.src = '/spritesheet.webp';
218
+ fetch(BLOB_CONFIG_URL)
219
+ .then(function (r) { if (!r.ok) throw new Error('blob config ' + r.status); return r.json(); })
220
+ .then(function (cfg) {
221
+ applyBlobConfig(cfg);
222
+ var img = new Image();
223
+ img.onload = function () {
224
+ spriteSheet = img;
225
+ center.x = Math.round(W / 2);
226
+ center.y = Math.round(H / 2);
227
+ currentFrame = IDLE_START;
228
+ animState = 'idle';
229
+ var splash = document.getElementById('splash');
230
+ if (splash) splash.style.display = 'none';
231
+ onDone();
232
+ };
233
+ img.onerror = function () { if (onFail) onFail(); };
234
+ img.src = BLOB_ASSETS_DIR + cfg.spritesheet;
235
+ })
236
+ .catch(function () { if (onFail) onFail(); });
237
+ }
238
+
239
+ // ── Load headphones sprite sheet (config + image) ──
240
+ function applyHpConfig(cfg) {
241
+ HP_COLS = cfg.grid.cols;
242
+ HP_FRAME_W = cfg.frame.w;
243
+ HP_FRAME_H = cfg.frame.h;
244
+ HP_BUBBLE_W = HP_BUBBLE_H * (HP_FRAME_W / HP_FRAME_H);
245
+ HP_IDLE_FRAME = cfg.clips.idle.from;
246
+ HP_ACTIVATE_START = cfg.clips.enter.from; HP_ACTIVATE_END = cfg.clips.enter.to;
247
+ HP_RECORD_START = cfg.clips.active.from; HP_RECORD_END = cfg.clips.active.to;
248
+ HP_EXIT_START = cfg.clips.exit.from; HP_EXIT_END = cfg.clips.exit.to;
249
+ HP_BASE_FPS = (cfg.clips.active.fps != null ? cfg.clips.active.fps : cfg.fps);
250
+ HP_ACTIVATE_FPS = (cfg.clips.enter.fps != null ? cfg.clips.enter.fps : cfg.fps);
251
+ HP_EXIT_FPS = (cfg.clips.exit.fps != null ? cfg.clips.exit.fps : cfg.fps);
199
252
  }
200
253
 
201
- // ── Load headphones sprite sheet ──
202
254
  function loadHpSprite(cb) {
203
255
  if (hpSpriteSheet) { if (cb) cb(); return; }
204
- var img = new Image();
205
- img.onload = function () { hpSpriteSheet = img; if (cb) cb(); };
206
- img.onerror = function () { console.error('[widget] headphones sprite failed'); };
207
- img.src = '/headphones_spritesheet.webp';
256
+ fetch(HP_CONFIG_URL)
257
+ .then(function (r) { if (!r.ok) throw new Error('hp config ' + r.status); return r.json(); })
258
+ .then(function (cfg) {
259
+ applyHpConfig(cfg);
260
+ var img = new Image();
261
+ img.onload = function () { hpSpriteSheet = img; hpFrame = HP_IDLE_FRAME; if (cb) cb(); };
262
+ img.onerror = function () { console.error('[widget] headphones sprite failed'); };
263
+ img.src = BLOB_ASSETS_DIR + cfg.spritesheet;
264
+ })
265
+ .catch(function (err) { console.error('[widget] headphones config failed', err); });
208
266
  }
209
267
 
210
268
  // ── Trigger blob move (splash) ──
@@ -230,7 +288,8 @@
230
288
  // ══════════════════════════════════════════════════════════════════
231
289
 
232
290
  function hpFps() {
233
- if (hpState === 'activating' || hpState === 'activating_then_deactivate' || hpState === 'deactivating') return HP_FAST_FPS;
291
+ if (hpState === 'activating' || hpState === 'activating_then_deactivate') return HP_ACTIVATE_FPS;
292
+ if (hpState === 'deactivating') return HP_EXIT_FPS;
234
293
  return HP_BASE_FPS;
235
294
  }
236
295
 
@@ -246,9 +305,8 @@
246
305
  hpFrame++;
247
306
  if (hpFrame > HP_ACTIVATE_END) {
248
307
  if (hpState === 'activating_then_deactivate') {
249
- // Reverse back to idle
250
308
  hpState = 'deactivating';
251
- hpFrame = HP_ACTIVATE_END;
309
+ hpFrame = HP_EXIT_START;
252
310
  } else {
253
311
  hpState = 'recording';
254
312
  hpFrame = HP_RECORD_START;
@@ -260,9 +318,8 @@
260
318
  if (hpFrame >= HP_RECORD_END) { hpFrame = HP_RECORD_END; hpPingPong = -1; }
261
319
  else if (hpFrame <= HP_RECORD_START) { hpFrame = HP_RECORD_START; hpPingPong = 1; }
262
320
  } else if (hpState === 'deactivating') {
263
- // v2: reverse 40→11
264
- hpFrame--;
265
- if (hpFrame < HP_ACTIVATE_START) {
321
+ hpFrame++;
322
+ if (hpFrame > HP_EXIT_END) {
266
323
  hpState = 'idle';
267
324
  hpFrame = HP_IDLE_FRAME;
268
325
  }
@@ -300,10 +357,11 @@
300
357
  else if (currentFrame <= IDLE_START) { currentFrame = IDLE_START; idleDirection = 1; }
301
358
  } else if (animState === 'melting') {
302
359
  currentFrame++;
303
- if (currentFrame > MELT_END) { animState = 'traveling'; currentFrame = TRAVEL_START; travelStartTime = now; }
360
+ if (currentFrame > MELT_END) { animState = 'traveling'; currentFrame = TRAVEL_START; travelStartTime = now; travelFrameDir = 1; }
304
361
  } else if (animState === 'traveling') {
305
- currentFrame++;
306
- if (currentFrame > TRAVEL_END) currentFrame = TRAVEL_START;
362
+ currentFrame += travelFrameDir;
363
+ if (currentFrame >= TRAVEL_END) { currentFrame = TRAVEL_END; travelFrameDir = -1; }
364
+ else if (currentFrame <= TRAVEL_START) { currentFrame = TRAVEL_START; travelFrameDir = 1; }
307
365
  } else if (animState === 'reforming') {
308
366
  currentFrame++;
309
367
  if (currentFrame > REFORM_END) {
@@ -540,12 +598,11 @@
540
598
  canvas.style.transition = 'transform 0.2s ease';
541
599
  canvas.style.transform = '';
542
600
 
543
- // v2 deactivation: reverse 40→11 (activate played backwards)
544
601
  if (hpState === 'activating') {
545
602
  hpState = 'activating_then_deactivate';
546
603
  } else if (hpState === 'recording') {
547
604
  hpState = 'deactivating';
548
- hpFrame = HP_ACTIVATE_END;
605
+ hpFrame = HP_EXIT_START;
549
606
  hpLastFrameTime = performance.now();
550
607
  }
551
608
  }
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "headphones",
3
+ "spritesheet": "headphones.png",
4
+ "frame": {
5
+ "w": 174,
6
+ "h": 180
7
+ },
8
+ "grid": {
9
+ "cols": 16,
10
+ "rows": 8
11
+ },
12
+ "totalFrames": 121,
13
+ "fps": 24,
14
+ "clips": {
15
+ "idle": {
16
+ "from": 15,
17
+ "to": 15,
18
+ "mode": "hold",
19
+ "fps": 38
20
+ },
21
+ "enter": {
22
+ "from": 16,
23
+ "to": 25,
24
+ "mode": "forward",
25
+ "fps": 48,
26
+ "next": "active"
27
+ },
28
+ "active": {
29
+ "from": 26,
30
+ "to": 94,
31
+ "mode": "pingpong"
32
+ },
33
+ "exit": {
34
+ "from": 96,
35
+ "to": 105,
36
+ "mode": "forward",
37
+ "next": "idle"
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "teleporting",
3
+ "spritesheet": "teleporting.png",
4
+ "frame": {
5
+ "w": 218,
6
+ "h": 180
7
+ },
8
+ "grid": {
9
+ "cols": 16,
10
+ "rows": 8
11
+ },
12
+ "totalFrames": 121,
13
+ "fps": 24,
14
+ "clips": {
15
+ "idle": {
16
+ "from": 19,
17
+ "to": 19,
18
+ "mode": "hold"
19
+ },
20
+ "enter": {
21
+ "from": 20,
22
+ "to": 45,
23
+ "mode": "forward",
24
+ "next": "active"
25
+ },
26
+ "active": {
27
+ "from": 46,
28
+ "to": 68,
29
+ "mode": "pingpong"
30
+ },
31
+ "exit": {
32
+ "from": 69,
33
+ "to": 121,
34
+ "mode": "forward",
35
+ "next": "idle"
36
+ }
37
+ }
38
+ }
@@ -6,7 +6,7 @@
6
6
  // JS/CSS modules → stale-while-revalidate
7
7
  // API, WebSocket, Vite internals → network-only (no cache)
8
8
 
9
- const CACHE = 'bloby-v5';
9
+ const CACHE = 'bloby-v7';
10
10
 
11
11
  // Precache the HTML shell on install so the cache is never empty.
12
12
  // Without this, the first navigation isn't intercepted (SW wasn't