@bloopjs/web 0.0.59 → 0.0.61

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.59",
3
+ "version": "0.0.61",
4
4
  "author": "Neil Sarkar",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,8 +33,8 @@
33
33
  "typescript": "^5"
34
34
  },
35
35
  "dependencies": {
36
- "@bloopjs/bloop": "0.0.59",
37
- "@bloopjs/engine": "0.0.59",
36
+ "@bloopjs/bloop": "0.0.61",
37
+ "@bloopjs/engine": "0.0.61",
38
38
  "@preact/signals": "^1.3.1",
39
39
  "partysocket": "^1.1.6",
40
40
  "preact": "^10.25.4"
package/src/App.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  import { type Bloop, type MountOpts, mount, type Sim } from "@bloopjs/bloop";
2
2
  import type { Key } from "@bloopjs/engine";
3
- import { mouseButtonCodeToMouseButton } from "@bloopjs/engine";
3
+ import { mouseButtonCodeToMouseButton, readTapeHeader } from "@bloopjs/engine";
4
+ import { DebugUi, type DebugUiOptions } from "./debugui/mod.ts";
5
+ import {
6
+ debugState,
7
+ triggerHmrFlash,
8
+ wirePlaybarHandlers,
9
+ wireTapeDragDrop,
10
+ } from "./debugui/state.ts";
4
11
  import {
5
12
  joinRoom as joinRoomInternal,
6
13
  type RoomEvents,
7
14
  } from "./netcode/broker";
8
15
  import { logger } from "./netcode/logs.ts";
9
- import { DebugUi, type DebugUiOptions } from "./debugui/mod.ts";
10
- import { debugState, triggerHmrFlash } from "./debugui/state.ts";
11
16
 
12
17
  export type StartOptions = {
13
18
  /** A bloop game instance */
@@ -41,7 +46,7 @@ export async function start(opts: StartOptions): Promise<App> {
41
46
 
42
47
  const debugOpts = opts.debugUi
43
48
  ? typeof opts.debugUi === "boolean"
44
- ? {}
49
+ ? { initiallyVisible: true }
45
50
  : opts.debugUi
46
51
  : undefined;
47
52
 
@@ -109,6 +114,11 @@ export class App {
109
114
  #initDebugUi(opts: DebugUiOptions = {}): DebugUi {
110
115
  if (this.#debugUi) return this.#debugUi;
111
116
  this.#debugUi = new DebugUi(opts);
117
+
118
+ // Wire up playbar handlers and drag-drop
119
+ wirePlaybarHandlers(this);
120
+ wireTapeDragDrop(this.#debugUi.canvas, this);
121
+
112
122
  return this.#debugUi;
113
123
  }
114
124
 
@@ -127,6 +137,21 @@ export class App {
127
137
  joinRoomInternal(this.brokerUrl, roomId, callbacks);
128
138
  }
129
139
 
140
+ /** Load a tape for replay */
141
+ loadTape(tape: Uint8Array): void {
142
+ const header = readTapeHeader(tape);
143
+ this.sim.loadTape(tape);
144
+ this.sim.seek(header.startFrame);
145
+ this.sim.pause();
146
+
147
+ // Update debug state with tape info
148
+ debugState.tapeStartFrame.value = header.startFrame;
149
+ debugState.tapeFrameCount.value = header.frameCount;
150
+ debugState.tapeUtilization.value = 1; // Loaded tape is "full"
151
+ debugState.playheadPosition.value = 0;
152
+ debugState.isPlaying.value = false;
153
+ }
154
+
130
155
  /** Event listeners for before a frame is processed */
131
156
  beforeFrame: ReturnType<typeof createListener> = createListener<[number]>();
132
157
  /** Event listeners for after a frame is processed */
@@ -136,38 +161,46 @@ export class App {
136
161
 
137
162
  /** Subscribe to the browser events and start the render loop */
138
163
  subscribe(): void {
164
+ // TODO: move this logic to the engine
165
+ // Skip emitting input events during replay to avoid filling the event buffer
166
+ const shouldEmitInputs = () => !this.sim.isReplaying;
167
+
139
168
  const handleKeydown = (event: KeyboardEvent) => {
140
- this.sim.emit.keydown(event.code as Key);
169
+ if (shouldEmitInputs()) this.sim.emit.keydown(event.code as Key);
141
170
  };
142
171
  window.addEventListener("keydown", handleKeydown);
143
172
 
144
173
  const handleKeyup = (event: KeyboardEvent) => {
145
- this.sim.emit.keyup(event.code as Key);
174
+ if (shouldEmitInputs()) this.sim.emit.keyup(event.code as Key);
146
175
  };
147
176
  window.addEventListener("keyup", handleKeyup);
148
177
 
149
178
  const handleMousemove = (event: MouseEvent) => {
150
- this.sim.emit.mousemove(event.clientX, event.clientY);
179
+ if (shouldEmitInputs()) this.sim.emit.mousemove(event.clientX, event.clientY);
151
180
  };
152
181
  window.addEventListener("mousemove", handleMousemove);
153
182
 
154
183
  const handleMousedown = (event: MouseEvent) => {
155
- this.sim.emit.mousedown(mouseButtonCodeToMouseButton(event.button + 1));
184
+ if (shouldEmitInputs())
185
+ this.sim.emit.mousedown(mouseButtonCodeToMouseButton(event.button + 1));
156
186
  };
157
187
  window.addEventListener("mousedown", handleMousedown);
158
188
 
159
189
  const handleMouseup = (event: MouseEvent) => {
160
- this.sim.emit.mouseup(mouseButtonCodeToMouseButton(event.button + 1));
190
+ if (shouldEmitInputs())
191
+ this.sim.emit.mouseup(mouseButtonCodeToMouseButton(event.button + 1));
161
192
  };
162
193
  window.addEventListener("mouseup", handleMouseup);
163
194
 
164
195
  const handleMousewheel = (event: WheelEvent) => {
165
- this.sim.emit.mousewheel(event.deltaX, event.deltaY);
196
+ if (shouldEmitInputs())
197
+ this.sim.emit.mousewheel(event.deltaX, event.deltaY);
166
198
  };
167
199
  window.addEventListener("wheel", handleMousewheel);
168
200
 
169
201
  // Touch events for mobile support
170
202
  const handleTouchstart = (event: TouchEvent) => {
203
+ if (!shouldEmitInputs()) return;
171
204
  const touch = event.touches[0];
172
205
  if (touch) {
173
206
  this.sim.emit.mousemove(touch.clientX, touch.clientY);
@@ -177,11 +210,12 @@ export class App {
177
210
  window.addEventListener("touchstart", handleTouchstart);
178
211
 
179
212
  const handleTouchend = () => {
180
- this.sim.emit.mouseup("Left");
213
+ if (shouldEmitInputs()) this.sim.emit.mouseup("Left");
181
214
  };
182
215
  window.addEventListener("touchend", handleTouchend);
183
216
 
184
217
  const handleTouchmove = (event: TouchEvent) => {
218
+ if (!shouldEmitInputs()) return;
185
219
  const touch = event.touches[0];
186
220
  if (touch) {
187
221
  this.sim.emit.mousemove(touch.clientX, touch.clientY);
@@ -190,21 +224,43 @@ export class App {
190
224
  window.addEventListener("touchmove", handleTouchmove);
191
225
 
192
226
  const playbarHotkeys = (event: KeyboardEvent) => {
227
+ // Ctrl/Cmd+S to save tape
228
+ if ((event.ctrlKey || event.metaKey) && event.key === "s") {
229
+ event.preventDefault();
230
+ if (this.sim.hasHistory) {
231
+ const tape = this.sim.saveTape();
232
+ const blob = new Blob([tape], { type: "application/octet-stream" });
233
+ const url = URL.createObjectURL(blob);
234
+ const a = document.createElement("a");
235
+ a.href = url;
236
+ a.download = `tape-${Date.now()}.bloop`;
237
+ a.click();
238
+ URL.revokeObjectURL(url);
239
+ }
240
+ return;
241
+ }
242
+
193
243
  const isPauseHotkey =
194
244
  event.key === "Enter" && (event.ctrlKey || event.metaKey);
195
245
  if (isPauseHotkey || event.key === "6") {
196
246
  this.sim.isPaused ? this.sim.unpause() : this.sim.pause();
197
247
  }
198
248
 
199
- if (this.sim.isPaused) {
249
+ if (this.sim.hasHistory) {
200
250
  switch (event.key) {
251
+ case "4":
252
+ debugState.onJumpBack.value?.();
253
+ break;
201
254
  case ",":
202
255
  case "5":
203
- this.sim.stepBack();
256
+ if (this.sim.isPaused) this.sim.stepBack();
204
257
  break;
205
258
  case ".":
206
259
  case "7":
207
- this.sim.step();
260
+ if (this.sim.isPaused) this.sim.seek(this.sim.time.frame + 1);
261
+ break;
262
+ case "8":
263
+ debugState.onJumpForward.value?.();
208
264
  break;
209
265
  }
210
266
  }
@@ -219,11 +275,13 @@ export class App {
219
275
  const stepStart = performance.now();
220
276
  const ticks = this.sim.step(stepStart - this.#now);
221
277
 
222
- // Update debug metrics only when we actually ran simulation
278
+ // Always update frame number (even when paused/stepping)
279
+ debugState.frameNumber.value = this.sim.time.frame;
280
+
281
+ // Update performance metrics only when we actually ran simulation
223
282
  if (ticks > 0) {
224
283
  const stepEnd = performance.now();
225
284
  debugState.frameTime.value = stepEnd - stepStart;
226
- debugState.frameNumber.value = this.sim.time.frame;
227
285
 
228
286
  // Measure snapshot size when debug UI is visible (letterboxed mode)
229
287
  if (debugState.layoutMode.value === "letterboxed") {
@@ -243,6 +301,25 @@ export class App {
243
301
  }
244
302
  }
245
303
 
304
+ // Update tape playback state
305
+ debugState.isPlaying.value = !this.sim.isPaused;
306
+ if (this.sim.hasHistory && debugState.tapeFrameCount.value > 0) {
307
+ const currentFrame = this.sim.time.frame;
308
+ const startFrame = debugState.tapeStartFrame.value;
309
+ const frameCount = debugState.tapeFrameCount.value;
310
+ const position = (currentFrame - startFrame) / frameCount;
311
+ debugState.playheadPosition.value = Math.max(0, Math.min(1, position));
312
+
313
+ // Auto-pause at end of tape
314
+ if (
315
+ this.sim.isReplaying &&
316
+ !this.sim.isPaused &&
317
+ currentFrame >= startFrame + frameCount
318
+ ) {
319
+ this.sim.pause();
320
+ }
321
+ }
322
+
246
323
  if (!this.sim.isPaused) {
247
324
  try {
248
325
  this.afterFrame.notify(this.sim.time.frame);
@@ -1,32 +1,129 @@
1
- type BottomBarProps = {
2
- tapeUtilization: number; // 0-1, how full the tape is
3
- playheadPosition: number; // 0-1, current position in tape
4
- isPlaying: boolean;
5
- };
6
-
7
- export function BottomBar({
8
- tapeUtilization = 0,
9
- playheadPosition = 0,
10
- isPlaying = true,
11
- }: BottomBarProps) {
12
- // Placeholder handlers - behavior not wired up yet
13
- const handleJumpBack = () => {};
14
- const handleStepBack = () => {};
15
- const handlePlayPause = () => {};
16
- const handleStepForward = () => {};
17
- const handleJumpForward = () => {};
18
- const handleSeek = () => {};
1
+ import { useRef, useCallback } from "preact/hooks";
2
+ import { debugState } from "../state.ts";
3
+
4
+ /** Hook that returns handlers for repeat-on-hold behavior with initial debounce */
5
+ function useRepeatOnHold(action: () => void) {
6
+ const rafId = useRef<number | null>(null);
7
+ const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
8
+
9
+ const startRepeat = useCallback(() => {
10
+ // Fire immediately on first press
11
+ action();
12
+
13
+ // Wait 300ms before starting repeat
14
+ timeoutId.current = setTimeout(() => {
15
+ const repeat = () => {
16
+ action();
17
+ rafId.current = requestAnimationFrame(repeat);
18
+ };
19
+ rafId.current = requestAnimationFrame(repeat);
20
+ }, 300);
21
+ }, [action]);
22
+
23
+ const stopRepeat = useCallback(() => {
24
+ if (timeoutId.current !== null) {
25
+ clearTimeout(timeoutId.current);
26
+ timeoutId.current = null;
27
+ }
28
+ if (rafId.current !== null) {
29
+ cancelAnimationFrame(rafId.current);
30
+ rafId.current = null;
31
+ }
32
+ }, []);
33
+
34
+ return {
35
+ onMouseDown: startRepeat,
36
+ onMouseUp: stopRepeat,
37
+ onMouseLeave: stopRepeat,
38
+ };
39
+ }
40
+
41
+ /** Hook for seek bar drag behavior */
42
+ function useSeekDrag(onSeek: (ratio: number) => void) {
43
+ const isDragging = useRef(false);
44
+ const targetRef = useRef<HTMLElement | null>(null);
45
+
46
+ const getRatio = (clientX: number, target: HTMLElement) => {
47
+ const rect = target.getBoundingClientRect();
48
+ const clickX = clientX - rect.left;
49
+ return Math.max(0, Math.min(1, clickX / rect.width));
50
+ };
51
+
52
+ const handleMouseDown = useCallback(
53
+ (e: { currentTarget: EventTarget; clientX: number }) => {
54
+ const target = e.currentTarget as HTMLElement;
55
+ isDragging.current = true;
56
+ targetRef.current = target;
57
+ onSeek(getRatio(e.clientX, target));
58
+
59
+ const handleMouseMove = (moveEvent: MouseEvent) => {
60
+ if (isDragging.current && targetRef.current) {
61
+ onSeek(getRatio(moveEvent.clientX, targetRef.current));
62
+ }
63
+ };
64
+
65
+ const handleMouseUp = () => {
66
+ isDragging.current = false;
67
+ targetRef.current = null;
68
+ window.removeEventListener("mousemove", handleMouseMove);
69
+ window.removeEventListener("mouseup", handleMouseUp);
70
+ };
71
+
72
+ window.addEventListener("mousemove", handleMouseMove);
73
+ window.addEventListener("mouseup", handleMouseUp);
74
+ },
75
+ [onSeek],
76
+ );
77
+
78
+ return { onMouseDown: handleMouseDown };
79
+ }
80
+
81
+ export function BottomBar() {
82
+ const isPlaying = debugState.isPlaying.value;
83
+ const tapeUtilization = debugState.tapeUtilization.value;
84
+ const playheadPosition = debugState.playheadPosition.value;
85
+
86
+ const handleJumpBack = useCallback(() => {
87
+ debugState.onJumpBack.value?.();
88
+ }, []);
89
+
90
+ const handleStepBack = useCallback(() => {
91
+ debugState.onStepBack.value?.();
92
+ }, []);
93
+
94
+ const handlePlayPause = useCallback(() => {
95
+ debugState.onPlayPause.value?.();
96
+ }, []);
97
+
98
+ const handleStepForward = useCallback(() => {
99
+ debugState.onStepForward.value?.();
100
+ }, []);
101
+
102
+ const handleJumpForward = useCallback(() => {
103
+ debugState.onJumpForward.value?.();
104
+ }, []);
105
+
106
+ const jumpBackRepeat = useRepeatOnHold(handleJumpBack);
107
+ const stepBackRepeat = useRepeatOnHold(handleStepBack);
108
+ const stepForwardRepeat = useRepeatOnHold(handleStepForward);
109
+ const jumpForwardRepeat = useRepeatOnHold(handleJumpForward);
110
+
111
+ const handleSeek = useCallback((ratio: number) => {
112
+ debugState.onSeek.value?.(ratio);
113
+ }, []);
114
+
115
+ const seekDrag = useSeekDrag(handleSeek);
19
116
 
20
117
  return (
21
118
  <div className="bottom-bar">
22
119
  <div className="playbar-controls">
23
- <button className="playbar-btn" onClick={handleJumpBack}>
120
+ <button className="playbar-btn" {...jumpBackRepeat}>
24
121
  {"<<"}
25
122
  <span className="tooltip tooltip-left">
26
123
  Jump back <kbd>4</kbd>
27
124
  </span>
28
125
  </button>
29
- <button className="playbar-btn" onClick={handleStepBack}>
126
+ <button className="playbar-btn" {...stepBackRepeat}>
30
127
  {"<"}
31
128
  <span className="tooltip">
32
129
  Step back <kbd>5</kbd>
@@ -38,20 +135,20 @@ export function BottomBar({
38
135
  {isPlaying ? "Pause" : "Play"} <kbd>6</kbd>
39
136
  </span>
40
137
  </button>
41
- <button className="playbar-btn" onClick={handleStepForward}>
138
+ <button className="playbar-btn" {...stepForwardRepeat}>
42
139
  {">"}
43
140
  <span className="tooltip">
44
141
  Step forward <kbd>7</kbd>
45
142
  </span>
46
143
  </button>
47
- <button className="playbar-btn" onClick={handleJumpForward}>
144
+ <button className="playbar-btn" {...jumpForwardRepeat}>
48
145
  {">>"}
49
146
  <span className="tooltip">
50
147
  Jump forward <kbd>8</kbd>
51
148
  </span>
52
149
  </button>
53
150
  </div>
54
- <div className="seek-bar" onClick={handleSeek}>
151
+ <div className="seek-bar" {...seekDrag}>
55
152
  <div
56
153
  className="seek-bar-fill"
57
154
  style={{ width: `${tapeUtilization * 100}%` }}
@@ -98,7 +98,7 @@ function LetterboxedLayout({ canvas }: { canvas: HTMLCanvasElement }) {
98
98
  max={rightMax}
99
99
  side="right"
100
100
  />
101
- <BottomBar tapeUtilization={0.67} playheadPosition={0.8} isPlaying={true} />
101
+ <BottomBar />
102
102
  </main>
103
103
  );
104
104
  }
@@ -4,6 +4,7 @@ import {
4
4
  type Signal,
5
5
  signal,
6
6
  } from "@preact/signals";
7
+ import type { App } from "../App";
7
8
  import type { Log } from "../netcode/logs.ts";
8
9
 
9
10
  export type FrameNumber = number;
@@ -39,6 +40,19 @@ export type DebugState = {
39
40
  frameNumber: Signal<number>;
40
41
  // HMR flash indicator
41
42
  hmrFlash: Signal<boolean>;
43
+ // Tape playback state
44
+ isPlaying: Signal<boolean>;
45
+ tapeUtilization: Signal<number>; // 0-1, how full the tape buffer is
46
+ playheadPosition: Signal<number>; // 0-1, current position in tape
47
+ tapeStartFrame: Signal<number>; // first frame in tape
48
+ tapeFrameCount: Signal<number>; // total frames in tape
49
+ // Playbar handlers (set by App)
50
+ onJumpBack: Signal<(() => void) | null>;
51
+ onStepBack: Signal<(() => void) | null>;
52
+ onPlayPause: Signal<(() => void) | null>;
53
+ onStepForward: Signal<(() => void) | null>;
54
+ onJumpForward: Signal<(() => void) | null>;
55
+ onSeek: Signal<((position: number) => void) | null>;
42
56
  };
43
57
 
44
58
  const layoutMode = signal<LayoutMode>("off");
@@ -55,6 +69,21 @@ const snapshotSize = signal(0);
55
69
  const frameNumber = signal(0);
56
70
  const hmrFlash = signal(false);
57
71
 
72
+ // Tape playback state
73
+ const isPlaying = signal(true);
74
+ const tapeUtilization = signal(0);
75
+ const playheadPosition = signal(0);
76
+ const tapeStartFrame = signal(0);
77
+ const tapeFrameCount = signal(0);
78
+
79
+ // Playbar handlers
80
+ const onJumpBack = signal<(() => void) | null>(null);
81
+ const onStepBack = signal<(() => void) | null>(null);
82
+ const onPlayPause = signal<(() => void) | null>(null);
83
+ const onStepForward = signal<(() => void) | null>(null);
84
+ const onJumpForward = signal<(() => void) | null>(null);
85
+ const onSeek = signal<((position: number) => void) | null>(null);
86
+
58
87
  export const debugState: DebugState = {
59
88
  /** Layout mode: off, letterboxed, or full */
60
89
  layoutMode,
@@ -85,6 +114,21 @@ export const debugState: DebugState = {
85
114
 
86
115
  /** HMR flash indicator */
87
116
  hmrFlash,
117
+
118
+ /** Tape playback state */
119
+ isPlaying,
120
+ tapeUtilization,
121
+ playheadPosition,
122
+ tapeStartFrame,
123
+ tapeFrameCount,
124
+
125
+ /** Playbar handlers */
126
+ onJumpBack,
127
+ onStepBack,
128
+ onPlayPause,
129
+ onStepForward,
130
+ onJumpForward,
131
+ onSeek,
88
132
  };
89
133
 
90
134
  /** Cycle through layout modes: off -> letterboxed -> full -> off */
@@ -201,4 +245,68 @@ export function resetState(): void {
201
245
  debugState.snapshotSize.value = 0;
202
246
  debugState.frameNumber.value = 0;
203
247
  debugState.hmrFlash.value = false;
248
+ // Tape state
249
+ debugState.isPlaying.value = true;
250
+ debugState.tapeUtilization.value = 0;
251
+ debugState.playheadPosition.value = 0;
252
+ debugState.tapeStartFrame.value = 0;
253
+ debugState.tapeFrameCount.value = 0;
254
+ // Don't reset handlers - they're set once by App
255
+ }
256
+
257
+ /** Wire up playbar handlers to control an App instance */
258
+ export function wirePlaybarHandlers(app: App): void {
259
+ debugState.onPlayPause.value = () => {
260
+ app.sim.isPaused ? app.sim.unpause() : app.sim.pause();
261
+ };
262
+ debugState.onStepBack.value = () => {
263
+ if (app.sim.hasHistory) app.sim.stepBack();
264
+ };
265
+ debugState.onStepForward.value = () => {
266
+ if (app.sim.hasHistory) {
267
+ app.sim.seek(app.sim.time.frame + 1);
268
+ }
269
+ };
270
+ debugState.onJumpBack.value = () => {
271
+ if (app.sim.hasHistory) {
272
+ const target = Math.max(
273
+ debugState.tapeStartFrame.value,
274
+ app.sim.time.frame - 10,
275
+ );
276
+ app.sim.seek(target);
277
+ }
278
+ };
279
+ debugState.onJumpForward.value = () => {
280
+ if (app.sim.hasHistory) {
281
+ const maxFrame =
282
+ debugState.tapeStartFrame.value + debugState.tapeFrameCount.value;
283
+ const target = Math.min(maxFrame, app.sim.time.frame + 10);
284
+ app.sim.seek(target);
285
+ }
286
+ };
287
+ debugState.onSeek.value = (ratio: number) => {
288
+ if (app.sim.hasHistory) {
289
+ const startFrame = debugState.tapeStartFrame.value;
290
+ const frameCount = debugState.tapeFrameCount.value;
291
+ const targetFrame = startFrame + Math.floor(ratio * frameCount);
292
+ app.sim.seek(targetFrame);
293
+ }
294
+ };
295
+ }
296
+
297
+ /** Set up drag-and-drop tape loading on a canvas element */
298
+ export function wireTapeDragDrop(canvas: HTMLCanvasElement, app: App): void {
299
+ canvas.addEventListener("dragover", (e) => {
300
+ e.preventDefault();
301
+ e.dataTransfer!.dropEffect = "copy";
302
+ });
303
+ canvas.addEventListener("drop", async (e) => {
304
+ e.preventDefault();
305
+ const file = e.dataTransfer?.files[0];
306
+ if (!file?.name.endsWith(".bloop")) {
307
+ return;
308
+ }
309
+ const bytes = new Uint8Array(await file.arrayBuffer());
310
+ app.loadTape(bytes);
311
+ });
204
312
  }
@@ -159,6 +159,7 @@ export const styles = /*css*/ `
159
159
  min-width: 18px;
160
160
  min-height: 18px;
161
161
  border: none;
162
+ outline: none;
162
163
  background: transparent;
163
164
  color: #888;
164
165
  font-size: 10px;