@bloopjs/web 0.0.102 → 0.0.103

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloopjs/web",
3
- "version": "0.0.102",
3
+ "version": "0.0.103",
4
4
  "author": "Neil Sarkar",
5
5
  "type": "module",
6
6
  "repository": {
@@ -37,8 +37,8 @@
37
37
  "typescript": "^5"
38
38
  },
39
39
  "dependencies": {
40
- "@bloopjs/bloop": "0.0.102",
41
- "@bloopjs/engine": "0.0.102",
40
+ "@bloopjs/bloop": "0.0.103",
41
+ "@bloopjs/engine": "0.0.103",
42
42
  "@preact/signals": "^1.3.1",
43
43
  "partysocket": "^1.1.6",
44
44
  "preact": "^10.25.4"
@@ -6,6 +6,11 @@ import { LoadTapeDialog } from "./LoadTapeDialog.tsx";
6
6
  const iconProps = { width: 14, height: 14, viewBox: "0 0 24 24", fill: "currentColor" };
7
7
 
8
8
  const Icons = {
9
+ record: (
10
+ <svg {...iconProps}>
11
+ <circle cx="12" cy="12" r="8" />
12
+ </svg>
13
+ ),
9
14
  jumpBack: (
10
15
  <svg {...iconProps}>
11
16
  <path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
@@ -171,15 +176,32 @@ export function BottomBar() {
171
176
  debugState.onSaveTape.value?.();
172
177
  }, []);
173
178
 
179
+ const handleToggleRecording = useCallback(() => {
180
+ debugState.onToggleRecording.value?.();
181
+ }, []);
182
+
183
+ const stopPropagation = useCallback((e: { stopPropagation: () => void }) => {
184
+ e.stopPropagation();
185
+ }, []);
186
+
174
187
  return (
175
- <div className="bottom-bar">
188
+ <div
189
+ className="bottom-bar"
190
+ onMouseDown={stopPropagation}
191
+ onMouseUp={stopPropagation}
192
+ onClick={stopPropagation}
193
+ >
176
194
  <div className="playbar-controls">
177
- {isRecording && (
178
- <span className="recording-indicator" title="Recording">
179
- <span className="recording-dot" />
180
- <span className="recording-label">REC</span>
195
+ <button
196
+ className={`playbar-btn record-btn ${isRecording ? "recording" : ""}`}
197
+ onClick={handleToggleRecording}
198
+ >
199
+ {Icons.record}
200
+ {isRecording && <span className="btn-label">REC</span>}
201
+ <span className="tooltip tooltip-left">
202
+ {isRecording ? "Stop recording" : "Start recording"}
181
203
  </span>
182
- )}
204
+ </button>
183
205
  {isReplaying && (
184
206
  <span className="replay-indicator" title="Replaying tape">
185
207
  REPLAY
@@ -7,7 +7,6 @@ export function LoadTapeDialog() {
7
7
  const [isDragOver, setIsDragOver] = useState(false);
8
8
 
9
9
  const isOpen = debugState.isLoadDialogOpen.value;
10
- const lastTapeName = debugState.lastTapeName.value;
11
10
 
12
11
  // Sync dialog open/close with signal
13
12
  useEffect(() => {
@@ -77,36 +76,63 @@ export function LoadTapeDialog() {
77
76
  debugState.onReplayLastTape.value?.();
78
77
  }, []);
79
78
 
79
+ const handleReplayLastSaved = useCallback(() => {
80
+ debugState.onReplayLastSaved.value?.();
81
+ }, []);
82
+
83
+ // Close dialog when clicking on backdrop
84
+ const handleDialogClick = useCallback(
85
+ (e: { target: EventTarget | null; currentTarget: EventTarget | null }) => {
86
+ // If click target is the dialog itself (backdrop), close it
87
+ if (e.target === e.currentTarget) {
88
+ debugState.isLoadDialogOpen.value = false;
89
+ }
90
+ },
91
+ [],
92
+ );
93
+
80
94
  return (
81
- <dialog ref={dialogRef} className="load-tape-dialog" onClose={handleClose}>
82
- <div className="load-tape-dialog-content">
83
- <h3>Load Tape</h3>
84
- <div
85
- className={`drop-zone ${isDragOver ? "drag-over" : ""}`}
86
- onClick={handleDropZoneClick}
87
- onDragOver={handleDragOver}
88
- onDragLeave={handleDragLeave}
89
- onDrop={handleDrop}
90
- >
91
- <span className="drop-zone-text">
92
- Drop .bloop file here
93
- <br />
94
- or click to browse
95
- </span>
95
+ <dialog
96
+ ref={dialogRef}
97
+ className="load-tape-dialog"
98
+ onClose={handleClose}
99
+ onClick={handleDialogClick}
100
+ >
101
+ {isOpen && (
102
+ <div className="load-tape-dialog-content">
103
+ <h3>Load Tape</h3>
104
+ <div
105
+ className={`drop-zone ${isDragOver ? "drag-over" : ""}`}
106
+ onClick={handleDropZoneClick}
107
+ onDragOver={handleDragOver}
108
+ onDragLeave={handleDragLeave}
109
+ onDrop={handleDrop}
110
+ >
111
+ <span className="drop-zone-text">
112
+ Drop .bloop file here
113
+ <br />
114
+ or click to browse
115
+ </span>
116
+ </div>
117
+ <input
118
+ ref={fileInputRef}
119
+ type="file"
120
+ accept=".bloop"
121
+ className="hidden-file-input"
122
+ onChange={handleFileInputChange}
123
+ />
124
+ {debugState.lastSavedTapeName.value && (
125
+ <button className="replay-last-btn" onClick={handleReplayLastSaved}>
126
+ Replay last saved tape
127
+ </button>
128
+ )}
129
+ {debugState.lastTapeName.value && (
130
+ <button className="replay-last-btn" onClick={handleReplayLast}>
131
+ Replay last loaded: {debugState.lastTapeName.value}
132
+ </button>
133
+ )}
96
134
  </div>
97
- <input
98
- ref={fileInputRef}
99
- type="file"
100
- accept=".bloop"
101
- className="hidden-file-input"
102
- onChange={handleFileInputChange}
103
- />
104
- {lastTapeName && (
105
- <button className="replay-last-btn" onClick={handleReplayLast}>
106
- Replay last: {lastTapeName}
107
- </button>
108
- )}
109
- </div>
135
+ )}
110
136
  </dialog>
111
137
  );
112
138
  }
@@ -61,10 +61,10 @@ function LetterboxedLayout({ canvas }: { canvas: HTMLCanvasElement }) {
61
61
  const snapshotSize = debugState.snapshotSize.value;
62
62
  const hmrFlash = debugState.hmrFlash.value;
63
63
 
64
- // Left bar: frame advantage (online) or frame time % (offline)
64
+ // Left bar: frame advantage (online) or frame time (offline)
65
65
  const leftValue = isOnline ? Math.abs(advantage) : frameTime;
66
66
  const leftMax = isOnline ? 10 : 16.67; // 10 frames advantage or 16.67ms budget
67
- const leftLabel = isOnline ? "ADV" : "MS";
67
+ const leftLabel = isOnline ? "adv" : "time";
68
68
  const leftColor = isOnline
69
69
  ? advantage >= 0
70
70
  ? "#4a9eff"
@@ -72,12 +72,20 @@ function LetterboxedLayout({ canvas }: { canvas: HTMLCanvasElement }) {
72
72
  : frameTime > 16.67
73
73
  ? "#ff4a4a"
74
74
  : "#4aff4a";
75
+ const leftDisplayValue = isOnline
76
+ ? `${advantage >= 0 ? "+" : ""}${advantage} frames`
77
+ : `${frameTime.toFixed(1)}ms`;
75
78
 
76
79
  // Right bar: rollback depth (online) or snapshot size (offline)
77
80
  // For now, we don't have rollback depth exposed, so use a placeholder
78
81
  const rightValue = isOnline ? 0 : snapshotSize;
79
- const rightMax = isOnline ? 10 : 10000; // 10k frames rollback or 10KB
80
- const rightLabel = isOnline ? "RB" : "KB";
82
+ const rightMax = isOnline ? 10 : 10000; // 10 frames rollback or 10KB
83
+ const rightLabel = isOnline ? "rb" : "size";
84
+ const rightDisplayValue = isOnline
85
+ ? "0 frames"
86
+ : snapshotSize >= 1000
87
+ ? `${(snapshotSize / 1000).toFixed(1)}kb`
88
+ : `${snapshotSize}b`;
81
89
 
82
90
  const gameClassName = hmrFlash ? "letterboxed-game hmr-flash" : "letterboxed-game";
83
91
 
@@ -89,6 +97,7 @@ function LetterboxedLayout({ canvas }: { canvas: HTMLCanvasElement }) {
89
97
  max={leftMax}
90
98
  side="left"
91
99
  color={leftColor}
100
+ displayValue={leftDisplayValue}
92
101
  />
93
102
  <div className={gameClassName}>
94
103
  <GameCanvas canvas={canvas} />
@@ -97,6 +106,7 @@ function LetterboxedLayout({ canvas }: { canvas: HTMLCanvasElement }) {
97
106
  value={rightValue}
98
107
  max={rightMax}
99
108
  side="right"
109
+ displayValue={rightDisplayValue}
100
110
  />
101
111
  <BottomBar />
102
112
  </main>
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "preact/hooks";
1
2
  import { debugState } from "../state.ts";
2
3
 
3
4
  type TopBarProps = {
@@ -11,8 +12,17 @@ export function TopBar({ leftLabel, rightLabel }: TopBarProps) {
11
12
  const rtt = debugState.netStatus.value.rtt;
12
13
  const isOnline = debugState.netStatus.value.peers.length > 0;
13
14
 
15
+ const stopPropagation = useCallback((e: { stopPropagation: () => void }) => {
16
+ e.stopPropagation();
17
+ }, []);
18
+
14
19
  return (
15
- <div className="top-bar">
20
+ <div
21
+ className="top-bar"
22
+ onMouseDown={stopPropagation}
23
+ onMouseUp={stopPropagation}
24
+ onClick={stopPropagation}
25
+ >
16
26
  <span className="top-bar-side-label">{leftLabel}</span>
17
27
  <div className="top-bar-center">
18
28
  <div className="top-bar-item">
@@ -1,8 +1,11 @@
1
+ import { useCallback } from "preact/hooks";
2
+
1
3
  type VerticalBarProps = {
2
4
  value: number;
3
5
  max: number;
4
6
  side: "left" | "right";
5
7
  color?: string;
8
+ displayValue?: string;
6
9
  };
7
10
 
8
11
  export function VerticalBar({
@@ -10,16 +13,29 @@ export function VerticalBar({
10
13
  max,
11
14
  side,
12
15
  color = "#4a9eff",
16
+ displayValue,
13
17
  }: VerticalBarProps) {
14
18
  const percentage = max > 0 ? Math.min(100, (value / max) * 100) : 0;
15
19
 
20
+ const stopPropagation = useCallback((e: { stopPropagation: () => void }) => {
21
+ e.stopPropagation();
22
+ }, []);
23
+
16
24
  return (
17
- <div className={`${side}-bar`}>
25
+ <div
26
+ className={`${side}-bar`}
27
+ onMouseDown={stopPropagation}
28
+ onMouseUp={stopPropagation}
29
+ onClick={stopPropagation}
30
+ >
18
31
  <div className="vertical-bar">
19
32
  <div
20
33
  className="vertical-bar-fill"
21
34
  style={{ height: `${percentage}%`, background: color }}
22
35
  />
36
+ {displayValue && (
37
+ <span className={`vertical-bar-popover ${side}`}>{displayValue}</span>
38
+ )}
23
39
  </div>
24
40
  </div>
25
41
  );
@@ -58,9 +58,13 @@ export type DebugState = {
58
58
  // Tape loading/saving
59
59
  onLoadTape: Signal<((bytes: Uint8Array, fileName: string) => void) | null>;
60
60
  onReplayLastTape: Signal<(() => void) | null>;
61
+ onReplayLastSaved: Signal<(() => void) | null>;
61
62
  onSaveTape: Signal<(() => void) | null>;
62
63
  lastTapeName: Signal<string | null>;
64
+ lastSavedTapeName: Signal<string | null>;
63
65
  isLoadDialogOpen: Signal<boolean>;
66
+ // Recording toggle
67
+ onToggleRecording: Signal<(() => void) | null>;
64
68
  };
65
69
 
66
70
  const layoutMode = signal<LayoutMode>("off");
@@ -97,10 +101,15 @@ const onSeek = signal<((position: number) => void) | null>(null);
97
101
  // Tape loading/saving
98
102
  const onLoadTape = signal<((bytes: Uint8Array, fileName: string) => void) | null>(null);
99
103
  const onReplayLastTape = signal<(() => void) | null>(null);
104
+ const onReplayLastSaved = signal<(() => void) | null>(null);
100
105
  const onSaveTape = signal<(() => void) | null>(null);
101
106
  const lastTapeName = signal<string | null>(null);
107
+ const lastSavedTapeName = signal<string | null>(null);
102
108
  const isLoadDialogOpen = signal(false);
103
109
 
110
+ // Recording toggle
111
+ const onToggleRecording = signal<(() => void) | null>(null);
112
+
104
113
  export const debugState: DebugState = {
105
114
  /** Layout mode: off, letterboxed, or full */
106
115
  layoutMode,
@@ -152,9 +161,14 @@ export const debugState: DebugState = {
152
161
  /** Tape loading/saving */
153
162
  onLoadTape,
154
163
  onReplayLastTape,
164
+ onReplayLastSaved,
155
165
  onSaveTape,
156
166
  lastTapeName,
167
+ lastSavedTapeName,
157
168
  isLoadDialogOpen,
169
+
170
+ /** Recording toggle */
171
+ onToggleRecording,
158
172
  };
159
173
 
160
174
  /** Cycle through layout modes: off -> letterboxed -> full -> off */
@@ -314,12 +328,20 @@ export function wirePlaybarHandlers(app: App): void {
314
328
  };
315
329
  debugState.onSeek.value = (ratio: number) => {
316
330
  if (app.sim.hasHistory) {
331
+ app.sim.pause();
317
332
  const startFrame = debugState.tapeStartFrame.value;
318
333
  const frameCount = debugState.tapeFrameCount.value;
319
334
  const targetFrame = startFrame + Math.floor(ratio * frameCount);
320
335
  app.sim.seek(targetFrame);
321
336
  }
322
337
  };
338
+ debugState.onToggleRecording.value = () => {
339
+ if (app.sim.isRecording) {
340
+ app.sim.stopRecording();
341
+ } else {
342
+ app.sim.record();
343
+ }
344
+ };
323
345
  }
324
346
 
325
347
  /** Set up drag-and-drop tape loading on a canvas element */
@@ -342,7 +364,8 @@ export function wireTapeDragDrop(canvas: HTMLCanvasElement, app: App): void {
342
364
  // IndexedDB helpers for tape persistence
343
365
  const TAPE_DB_NAME = "bloop-debug";
344
366
  const TAPE_STORE_NAME = "tapes";
345
- const TAPE_KEY = "last";
367
+ const TAPE_KEY_LOADED = "last-loaded";
368
+ const TAPE_KEY_SAVED = "last-saved";
346
369
 
347
370
  function openTapeDB(): Promise<IDBDatabase> {
348
371
  return new Promise((resolve, reject) => {
@@ -358,17 +381,18 @@ function openTapeDB(): Promise<IDBDatabase> {
358
381
  async function saveTapeToStorage(
359
382
  bytes: Uint8Array,
360
383
  fileName: string,
384
+ key: string = TAPE_KEY_LOADED,
361
385
  ): Promise<void> {
362
386
  const db = await openTapeDB();
363
387
  return new Promise((resolve, reject) => {
364
388
  const tx = db.transaction(TAPE_STORE_NAME, "readwrite");
365
- tx.objectStore(TAPE_STORE_NAME).put({ bytes, fileName }, TAPE_KEY);
389
+ tx.objectStore(TAPE_STORE_NAME).put({ bytes, fileName }, key);
366
390
  tx.oncomplete = () => resolve();
367
391
  tx.onerror = () => reject(tx.error);
368
392
  });
369
393
  }
370
394
 
371
- async function loadTapeFromStorage(): Promise<{
395
+ async function loadTapeFromStorage(key: string = TAPE_KEY_LOADED): Promise<{
372
396
  bytes: Uint8Array;
373
397
  fileName: string;
374
398
  } | null> {
@@ -376,7 +400,7 @@ async function loadTapeFromStorage(): Promise<{
376
400
  const db = await openTapeDB();
377
401
  return new Promise((resolve, reject) => {
378
402
  const tx = db.transaction(TAPE_STORE_NAME, "readonly");
379
- const request = tx.objectStore(TAPE_STORE_NAME).get(TAPE_KEY);
403
+ const request = tx.objectStore(TAPE_STORE_NAME).get(key);
380
404
  request.onsuccess = () => resolve(request.result ?? null);
381
405
  request.onerror = () => reject(request.error);
382
406
  });
@@ -385,37 +409,54 @@ async function loadTapeFromStorage(): Promise<{
385
409
  }
386
410
  }
387
411
 
388
- /** Check for saved tape and update lastTapeName signal */
412
+ /** Check for saved tapes and update signals */
389
413
  export async function checkForSavedTape(): Promise<void> {
390
- const saved = await loadTapeFromStorage();
391
- debugState.lastTapeName.value = saved?.fileName ?? null;
414
+ const [loaded, saved] = await Promise.all([
415
+ loadTapeFromStorage(TAPE_KEY_LOADED),
416
+ loadTapeFromStorage(TAPE_KEY_SAVED),
417
+ ]);
418
+ debugState.lastTapeName.value = loaded?.fileName ?? null;
419
+ debugState.lastSavedTapeName.value = saved?.fileName ?? null;
392
420
  }
393
421
 
394
422
  /** Wire up tape loading handlers */
395
423
  export function wireTapeLoadHandlers(app: App): void {
396
424
  debugState.onLoadTape.value = async (bytes: Uint8Array, fileName: string) => {
397
425
  app.loadTape(bytes);
398
- await saveTapeToStorage(bytes, fileName);
426
+ await saveTapeToStorage(bytes, fileName, TAPE_KEY_LOADED);
399
427
  debugState.lastTapeName.value = fileName;
400
428
  debugState.isLoadDialogOpen.value = false;
401
429
  };
402
430
 
403
431
  debugState.onReplayLastTape.value = async () => {
404
- const saved = await loadTapeFromStorage();
432
+ const saved = await loadTapeFromStorage(TAPE_KEY_LOADED);
433
+ if (saved) {
434
+ app.loadTape(saved.bytes);
435
+ debugState.isLoadDialogOpen.value = false;
436
+ }
437
+ };
438
+
439
+ debugState.onReplayLastSaved.value = async () => {
440
+ const saved = await loadTapeFromStorage(TAPE_KEY_SAVED);
405
441
  if (saved) {
406
442
  app.loadTape(saved.bytes);
407
443
  debugState.isLoadDialogOpen.value = false;
408
444
  }
409
445
  };
410
446
 
411
- debugState.onSaveTape.value = () => {
447
+ debugState.onSaveTape.value = async () => {
412
448
  if (!app.sim.hasHistory) return;
413
449
  const tape = app.sim.saveTape();
450
+ const fileName = `tape-${Date.now()}.bloop`;
451
+ // Persist to IndexedDB for later replay
452
+ await saveTapeToStorage(tape, fileName, TAPE_KEY_SAVED);
453
+ debugState.lastSavedTapeName.value = fileName;
454
+ // Download
414
455
  const blob = new Blob([tape], { type: "application/octet-stream" });
415
456
  const url = URL.createObjectURL(blob);
416
457
  const a = document.createElement("a");
417
458
  a.href = url;
418
- a.download = `tape-${Date.now()}.bloop`;
459
+ a.download = fileName;
419
460
  a.click();
420
461
  URL.revokeObjectURL(url);
421
462
  };
@@ -160,6 +160,7 @@ export const styles = /*css*/ `
160
160
  font-family: monospace;
161
161
  font-size: 12px;
162
162
  padding: 0;
163
+ user-select: none;
163
164
  }
164
165
 
165
166
  .top-bar-side-label {
@@ -213,6 +214,7 @@ export const styles = /*css*/ `
213
214
  justify-content: flex-end;
214
215
  background: #111;
215
216
  padding: 4px 0;
217
+ user-select: none;
216
218
  }
217
219
 
218
220
  .right-bar {
@@ -223,6 +225,7 @@ export const styles = /*css*/ `
223
225
  justify-content: flex-end;
224
226
  background: #111;
225
227
  padding: 4px 0;
228
+ user-select: none;
226
229
  }
227
230
 
228
231
  .vertical-bar {
@@ -231,7 +234,6 @@ export const styles = /*css*/ `
231
234
  background: #333;
232
235
  border-radius: 2px;
233
236
  position: relative;
234
- overflow: hidden;
235
237
  }
236
238
 
237
239
  .vertical-bar-fill {
@@ -244,6 +246,37 @@ export const styles = /*css*/ `
244
246
  transition: height 0.1s ease-out;
245
247
  }
246
248
 
249
+ .vertical-bar-popover {
250
+ position: absolute;
251
+ top: 50%;
252
+ transform: translateY(-50%);
253
+ background: #222;
254
+ color: #ccc;
255
+ padding: 4px 8px;
256
+ border-radius: 4px;
257
+ font-size: 10px;
258
+ font-family: monospace;
259
+ white-space: nowrap;
260
+ opacity: 0;
261
+ visibility: hidden;
262
+ transition: opacity 0.15s;
263
+ pointer-events: none;
264
+ z-index: 10;
265
+ }
266
+
267
+ .vertical-bar-popover.left {
268
+ left: calc(100% + 8px);
269
+ }
270
+
271
+ .vertical-bar-popover.right {
272
+ right: calc(100% + 8px);
273
+ }
274
+
275
+ .vertical-bar:hover .vertical-bar-popover {
276
+ opacity: 1;
277
+ visibility: visible;
278
+ }
279
+
247
280
 
248
281
  .bottom-bar {
249
282
  grid-area: bottom-bar;
@@ -253,6 +286,7 @@ export const styles = /*css*/ `
253
286
  /* Mobile-first: more padding */
254
287
  padding: 0 16px;
255
288
  gap: 12px;
289
+ user-select: none;
256
290
  }
257
291
 
258
292
  /* Desktop: tighter padding */
@@ -270,22 +304,16 @@ export const styles = /*css*/ `
270
304
  flex-shrink: 0;
271
305
  }
272
306
 
273
- /* Recording indicator - mobile: just the dot */
274
- .recording-indicator {
275
- display: flex;
276
- align-items: center;
277
- margin-right: 4px;
307
+ /* Record button */
308
+ .record-btn {
309
+ color: #666;
278
310
  }
279
311
 
280
- .recording-indicator .recording-label {
281
- display: none;
312
+ .record-btn.recording {
313
+ color: #ff4444;
282
314
  }
283
315
 
284
- .recording-dot {
285
- width: 10px;
286
- height: 10px;
287
- background: #ff4444;
288
- border-radius: 50%;
316
+ .record-btn.recording svg {
289
317
  animation: recording-pulse 1s ease-in-out infinite;
290
318
  }
291
319
 
@@ -294,6 +322,17 @@ export const styles = /*css*/ `
294
322
  50% { opacity: 0.4; }
295
323
  }
296
324
 
325
+ /* Desktop: show REC label when recording */
326
+ @media (min-width: 769px) {
327
+ .record-btn.recording {
328
+ width: auto;
329
+ padding: 0 6px;
330
+ gap: 4px;
331
+ background: rgba(255, 68, 68, 0.15);
332
+ border-radius: 3px;
333
+ }
334
+ }
335
+
297
336
  /* Replay indicator - mobile: hidden */
298
337
  .replay-indicator {
299
338
  display: none;
@@ -455,6 +494,7 @@ export const styles = /*css*/ `
455
494
  position: relative;
456
495
  cursor: pointer;
457
496
  overflow: hidden;
497
+ user-select: none;
458
498
  }
459
499
 
460
500
  /* Desktop: smaller seek bar */