@bloopjs/web 0.0.56 → 0.0.58

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.56",
3
+ "version": "0.0.58",
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.56",
37
- "@bloopjs/engine": "0.0.56",
36
+ "@bloopjs/bloop": "0.0.58",
37
+ "@bloopjs/engine": "0.0.58",
38
38
  "@preact/signals": "^1.3.1",
39
39
  "partysocket": "^1.1.6",
40
40
  "preact": "^10.25.4"
package/src/App.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from "./netcode/broker";
8
8
  import { logger } from "./netcode/logs.ts";
9
9
  import { DebugUi, type DebugUiOptions } from "./debugui/mod.ts";
10
+ import { debugState, triggerHmrFlash } from "./debugui/state.ts";
10
11
 
11
12
  export type StartOptions = {
12
13
  /** A bloop game instance */
@@ -130,6 +131,8 @@ export class App {
130
131
  beforeFrame: ReturnType<typeof createListener> = createListener<[number]>();
131
132
  /** Event listeners for after a frame is processed */
132
133
  afterFrame: ReturnType<typeof createListener> = createListener<[number]>();
134
+ /** Event listeners for HMR events */
135
+ onHmr: ReturnType<typeof createListener> = createListener<[HmrEvent]>();
133
136
 
134
137
  /** Subscribe to the browser events and start the render loop */
135
138
  subscribe(): void {
@@ -208,8 +211,38 @@ export class App {
208
211
  };
209
212
  window.addEventListener("keydown", playbarHotkeys);
210
213
 
214
+ // FPS calculation
215
+ let fpsFrames = 0;
216
+ let fpsLastTime = performance.now();
217
+
211
218
  const frame = () => {
212
- this.sim.step(performance.now() - this.#now);
219
+ const stepStart = performance.now();
220
+ const ticks = this.sim.step(stepStart - this.#now);
221
+
222
+ // Update debug metrics only when we actually ran simulation
223
+ if (ticks > 0) {
224
+ const stepEnd = performance.now();
225
+ debugState.frameTime.value = stepEnd - stepStart;
226
+ debugState.frameNumber.value = this.sim.time.frame;
227
+
228
+ // Measure snapshot size when debug UI is visible (letterboxed mode)
229
+ if (debugState.layoutMode.value === "letterboxed") {
230
+ const bag = this.game.bag;
231
+ if (bag) {
232
+ debugState.snapshotSize.value = JSON.stringify(bag).length;
233
+ }
234
+ }
235
+
236
+ // Calculate FPS every second
237
+ fpsFrames++;
238
+ const elapsed = stepEnd - fpsLastTime;
239
+ if (elapsed >= 1000) {
240
+ debugState.fps.value = Math.round((fpsFrames * 1000) / elapsed);
241
+ fpsFrames = 0;
242
+ fpsLastTime = stepEnd;
243
+ }
244
+ }
245
+
213
246
  if (!this.sim.isPaused) {
214
247
  try {
215
248
  this.afterFrame.notify(this.sim.time.frame);
@@ -245,6 +278,7 @@ export class App {
245
278
  this.sim.unmount();
246
279
  this.beforeFrame.unsubscribeAll();
247
280
  this.afterFrame.unsubscribeAll();
281
+ this.onHmr.unsubscribeAll();
248
282
  this.#debugUi?.unmount();
249
283
  }
250
284
 
@@ -257,10 +291,14 @@ export class App {
257
291
  * import.meta.hot?.accept("./game", async (newModule) => {
258
292
  * await app.acceptHmr(newModule?.game, {
259
293
  * wasmUrl: monorepoWasmUrl,
294
+ * files: ["./game"],
260
295
  * });
261
296
  * ```
262
297
  */
263
- async acceptHmr(module: any, opts?: Partial<MountOpts>): Promise<void> {
298
+ async acceptHmr(
299
+ module: any,
300
+ opts?: Partial<MountOpts> & { files?: string[] },
301
+ ): Promise<void> {
264
302
  const game = (module.game ?? module) as Bloop<any>;
265
303
  if (!game.hooks) {
266
304
  throw new Error(
@@ -279,6 +317,10 @@ export class App {
279
317
  this.sim.unmount();
280
318
  this.sim = sim;
281
319
  this.game = game;
320
+
321
+ // Trigger HMR flash and notify listeners
322
+ triggerHmrFlash();
323
+ this.onHmr.notify({ files: opts?.files ?? [] });
282
324
  }
283
325
  }
284
326
 
@@ -312,3 +354,7 @@ function createListener<T extends any[]>(): {
312
354
  }
313
355
 
314
356
  export type UnsubscribeFn = () => void;
357
+
358
+ export type HmrEvent = {
359
+ files: string[];
360
+ };
@@ -1,6 +1,6 @@
1
1
  import { type ComponentChild, render } from "preact";
2
2
  import { Root } from "./components/Root.tsx";
3
- import { type DebugState, debugState } from "./state.ts";
3
+ import { type DebugState, cycleLayout, debugState } from "./state.ts";
4
4
  import { styles } from "./styles.ts";
5
5
 
6
6
  export type DebugUiOptions = {
@@ -30,7 +30,7 @@ export class DebugUi {
30
30
  // Create host element
31
31
  this.#host = document.createElement("bloop-debug-ui");
32
32
  this.#host.style.cssText =
33
- "display:block;width:100%;height:100%;position:absolute;top:0;left:0;";
33
+ "display:block;width:100%;height:100%;position:absolute;top:0;left:0;overflow:hidden;overscroll-behavior:none;";
34
34
 
35
35
  // Attach shadow DOM
36
36
  this.#shadow = this.#host.attachShadow({ mode: "open" });
@@ -47,7 +47,7 @@ export class DebugUi {
47
47
  this.#shadow.appendChild(this.#mountPoint);
48
48
 
49
49
  // Initialize state
50
- debugState.isVisible.value = initiallyVisible;
50
+ debugState.layoutMode.value = initiallyVisible ? "letterboxed" : "off";
51
51
 
52
52
  // Create canvas element (game renders here)
53
53
  this.#canvas = document.createElement("canvas");
@@ -61,8 +61,8 @@ export class DebugUi {
61
61
  // Set up hotkey listener
62
62
  this.#cleanup = this.#setupHotkey();
63
63
 
64
- // Re-render when isVisible changes
65
- debugState.isVisible.subscribe(() => {
64
+ // Re-render when layoutMode changes
65
+ debugState.layoutMode.subscribe(() => {
66
66
  this.#render();
67
67
  });
68
68
  }
@@ -77,7 +77,7 @@ export class DebugUi {
77
77
  #setupHotkey(): () => void {
78
78
  const handler = (e: KeyboardEvent) => {
79
79
  if (e.key === this.#hotkey) {
80
- debugState.isVisible.value = !debugState.isVisible.value;
80
+ cycleLayout();
81
81
  }
82
82
  };
83
83
  window.addEventListener("keydown", handler);
@@ -100,7 +100,7 @@ export class DebugUi {
100
100
  }
101
101
 
102
102
  set isVisible(value: boolean) {
103
- debugState.isVisible.value = value;
103
+ debugState.layoutMode.value = value ? "letterboxed" : "off";
104
104
  }
105
105
 
106
106
  unmount(): void {
@@ -0,0 +1,68 @@
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 = () => {};
19
+
20
+ return (
21
+ <div className="bottom-bar">
22
+ <div className="playbar-controls">
23
+ <button className="playbar-btn" onClick={handleJumpBack}>
24
+ {"<<"}
25
+ <span className="tooltip tooltip-left">
26
+ Jump back <kbd>4</kbd>
27
+ </span>
28
+ </button>
29
+ <button className="playbar-btn" onClick={handleStepBack}>
30
+ {"<"}
31
+ <span className="tooltip">
32
+ Step back <kbd>5</kbd>
33
+ </span>
34
+ </button>
35
+ <button className="playbar-btn" onClick={handlePlayPause}>
36
+ {isPlaying ? "||" : ">"}
37
+ <span className="tooltip">
38
+ {isPlaying ? "Pause" : "Play"} <kbd>6</kbd>
39
+ </span>
40
+ </button>
41
+ <button className="playbar-btn" onClick={handleStepForward}>
42
+ {">"}
43
+ <span className="tooltip">
44
+ Step forward <kbd>7</kbd>
45
+ </span>
46
+ </button>
47
+ <button className="playbar-btn" onClick={handleJumpForward}>
48
+ {">>"}
49
+ <span className="tooltip">
50
+ Jump forward <kbd>8</kbd>
51
+ </span>
52
+ </button>
53
+ </div>
54
+ <div className="seek-bar" onClick={handleSeek}>
55
+ <div
56
+ className="seek-bar-fill"
57
+ style={{ width: `${tapeUtilization * 100}%` }}
58
+ />
59
+ {tapeUtilization > 0 && (
60
+ <div
61
+ className="seek-bar-position"
62
+ style={{ left: `${playheadPosition * tapeUtilization * 100}%` }}
63
+ />
64
+ )}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -1,4 +1,4 @@
1
- import { debugState } from "../state.ts";
1
+ import { cycleLayout, debugState } from "../state.ts";
2
2
 
3
3
  type DebugToggleProps = {
4
4
  hotkey?: string;
@@ -7,14 +7,10 @@ type DebugToggleProps = {
7
7
  export function DebugToggle({ hotkey = "Escape" }: DebugToggleProps) {
8
8
  const isVisible = debugState.isVisible.value;
9
9
 
10
- const toggle = () => {
11
- debugState.isVisible.value = !debugState.isVisible.value;
12
- };
13
-
14
10
  return (
15
11
  <button
16
12
  className="debug-toggle"
17
- onClick={toggle}
13
+ onClick={cycleLayout}
18
14
  onMouseDown={(e) => e.stopPropagation()}
19
15
  onMouseUp={(e) => e.stopPropagation()}
20
16
  title={isVisible ? `Hide debug (${hotkey})` : `Show debug (${hotkey})`}
@@ -3,6 +3,9 @@ import { debugState } from "../state.ts";
3
3
  import { Stats } from "./Stats.tsx";
4
4
  import { Logs } from "./Logs.tsx";
5
5
  import { DebugToggle } from "./DebugToggle.tsx";
6
+ import { TopBar } from "./TopBar.tsx";
7
+ import { VerticalBar } from "./VerticalBar.tsx";
8
+ import { BottomBar } from "./BottomBar.tsx";
6
9
 
7
10
  type RootProps = {
8
11
  canvas: HTMLCanvasElement;
@@ -10,32 +13,96 @@ type RootProps = {
10
13
  };
11
14
 
12
15
  export function Root({ canvas, hotkey = "Escape" }: RootProps) {
13
- const isVisible = debugState.isVisible.value;
16
+ const layoutMode = debugState.layoutMode.value;
14
17
 
15
- return (
16
- <>
17
- {isVisible ? (
18
- <main className="layout">
19
- <section className="game">
20
- <GameCanvas canvas={canvas} />
21
- </section>
22
- <section className="stats">
23
- <Stats />
24
- </section>
25
- <section className="logs">
26
- <Logs />
27
- </section>
28
- </main>
29
- ) : (
18
+ if (layoutMode === "off") {
19
+ return (
20
+ <>
30
21
  <main className="fullscreen">
31
22
  <GameCanvas canvas={canvas} />
32
23
  </main>
33
- )}
24
+ <DebugToggle hotkey={hotkey} />
25
+ </>
26
+ );
27
+ }
28
+
29
+ if (layoutMode === "letterboxed") {
30
+ return (
31
+ <>
32
+ <LetterboxedLayout canvas={canvas} />
33
+ <DebugToggle hotkey={hotkey} />
34
+ </>
35
+ );
36
+ }
37
+
38
+ // Full layout (netcode debug)
39
+ return (
40
+ <>
41
+ <main className="layout">
42
+ <section className="game">
43
+ <GameCanvas canvas={canvas} />
44
+ </section>
45
+ <section className="stats">
46
+ <Stats />
47
+ </section>
48
+ <section className="logs">
49
+ <Logs />
50
+ </section>
51
+ </main>
34
52
  <DebugToggle hotkey={hotkey} />
35
53
  </>
36
54
  );
37
55
  }
38
56
 
57
+ function LetterboxedLayout({ canvas }: { canvas: HTMLCanvasElement }) {
58
+ const isOnline = debugState.netStatus.value.peers.length > 0;
59
+ const advantage = debugState.advantage.value ?? 0;
60
+ const frameTime = debugState.frameTime.value;
61
+ const snapshotSize = debugState.snapshotSize.value;
62
+ const hmrFlash = debugState.hmrFlash.value;
63
+
64
+ // Left bar: frame advantage (online) or frame time % (offline)
65
+ const leftValue = isOnline ? Math.abs(advantage) : frameTime;
66
+ const leftMax = isOnline ? 10 : 16.67; // 10 frames advantage or 16.67ms budget
67
+ const leftLabel = isOnline ? "ADV" : "MS";
68
+ const leftColor = isOnline
69
+ ? advantage >= 0
70
+ ? "#4a9eff"
71
+ : "#ff4a4a"
72
+ : frameTime > 16.67
73
+ ? "#ff4a4a"
74
+ : "#4aff4a";
75
+
76
+ // Right bar: rollback depth (online) or snapshot size (offline)
77
+ // For now, we don't have rollback depth exposed, so use a placeholder
78
+ const rightValue = isOnline ? 0 : snapshotSize;
79
+ const rightMax = isOnline ? 10 : 10000; // 10 frames rollback or 10KB
80
+ const rightLabel = isOnline ? "RB" : "KB";
81
+
82
+ const gameClassName = hmrFlash ? "letterboxed-game hmr-flash" : "letterboxed-game";
83
+
84
+ return (
85
+ <main className="layout-letterboxed">
86
+ <TopBar leftLabel={leftLabel} rightLabel={rightLabel} />
87
+ <VerticalBar
88
+ value={leftValue}
89
+ max={leftMax}
90
+ side="left"
91
+ color={leftColor}
92
+ />
93
+ <div className={gameClassName}>
94
+ <GameCanvas canvas={canvas} />
95
+ </div>
96
+ <VerticalBar
97
+ value={rightValue}
98
+ max={rightMax}
99
+ side="right"
100
+ />
101
+ <BottomBar tapeUtilization={0.67} playheadPosition={0.8} isPlaying={true} />
102
+ </main>
103
+ );
104
+ }
105
+
39
106
  function GameCanvas({ canvas }: { canvas: HTMLCanvasElement }) {
40
107
  const containerRef = useRef<HTMLDivElement>(null);
41
108
 
@@ -0,0 +1,36 @@
1
+ import { debugState } from "../state.ts";
2
+
3
+ type TopBarProps = {
4
+ leftLabel: string;
5
+ rightLabel: string;
6
+ };
7
+
8
+ export function TopBar({ leftLabel, rightLabel }: TopBarProps) {
9
+ const fps = debugState.fps.value;
10
+ const frameNumber = debugState.frameNumber.value;
11
+ const rtt = debugState.netStatus.value.rtt;
12
+ const isOnline = debugState.netStatus.value.peers.length > 0;
13
+
14
+ return (
15
+ <div className="top-bar">
16
+ <span className="top-bar-side-label">{leftLabel}</span>
17
+ <div className="top-bar-center">
18
+ <div className="top-bar-item">
19
+ <span className="top-bar-label">FPS</span>
20
+ <span className="top-bar-value">{fps}</span>
21
+ </div>
22
+ <div className="top-bar-item">
23
+ <span className="top-bar-label">Frame</span>
24
+ <span className="top-bar-value">{frameNumber}</span>
25
+ </div>
26
+ {isOnline && rtt !== null && (
27
+ <div className="top-bar-item">
28
+ <span className="top-bar-label">Ping</span>
29
+ <span className="top-bar-value">{rtt}ms</span>
30
+ </div>
31
+ )}
32
+ </div>
33
+ <span className="top-bar-side-label">{rightLabel}</span>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,26 @@
1
+ type VerticalBarProps = {
2
+ value: number;
3
+ max: number;
4
+ side: "left" | "right";
5
+ color?: string;
6
+ };
7
+
8
+ export function VerticalBar({
9
+ value,
10
+ max,
11
+ side,
12
+ color = "#4a9eff",
13
+ }: VerticalBarProps) {
14
+ const percentage = max > 0 ? Math.min(100, (value / max) * 100) : 0;
15
+
16
+ return (
17
+ <div className={`${side}-bar`}>
18
+ <div className="vertical-bar">
19
+ <div
20
+ className="vertical-bar-fill"
21
+ style={{ height: `${percentage}%`, background: color }}
22
+ />
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -8,6 +8,8 @@ import type { Log } from "../netcode/logs.ts";
8
8
 
9
9
  export type FrameNumber = number;
10
10
 
11
+ export type LayoutMode = "off" | "letterboxed" | "full";
12
+
11
13
  export type Peer = {
12
14
  id: string;
13
15
  nickname: string;
@@ -24,14 +26,22 @@ export type NetStatus = {
24
26
  };
25
27
 
26
28
  export type DebugState = {
27
- isVisible: Signal<boolean>;
29
+ layoutMode: Signal<LayoutMode>;
30
+ isVisible: ReadonlySignal<boolean>;
28
31
  netStatus: Signal<NetStatus>;
29
32
  logs: Signal<Log[]>;
30
33
  peer: ReadonlySignal<Peer | null>;
31
34
  advantage: ReadonlySignal<number | null>;
35
+ // Metrics for letterboxed layout
36
+ fps: Signal<number>;
37
+ frameTime: Signal<number>; // ms per frame
38
+ snapshotSize: Signal<number>; // bytes
39
+ frameNumber: Signal<number>;
40
+ // HMR flash indicator
41
+ hmrFlash: Signal<boolean>;
32
42
  };
33
43
 
34
- const isVisible = signal(false);
44
+ const layoutMode = signal<LayoutMode>("off");
35
45
  const netStatus = signal<NetStatus>({
36
46
  ourId: null,
37
47
  remoteId: null,
@@ -39,10 +49,18 @@ const netStatus = signal<NetStatus>({
39
49
  peers: [],
40
50
  });
41
51
  const logs = signal<Log[]>([]);
52
+ const fps = signal(0);
53
+ const frameTime = signal(0);
54
+ const snapshotSize = signal(0);
55
+ const frameNumber = signal(0);
56
+ const hmrFlash = signal(false);
42
57
 
43
58
  export const debugState: DebugState = {
44
- /** Whether debug UI is visible */
45
- isVisible,
59
+ /** Layout mode: off, letterboxed, or full */
60
+ layoutMode,
61
+
62
+ /** Whether debug UI is visible (derived from layoutMode) */
63
+ isVisible: computed(() => layoutMode.value !== "off"),
46
64
 
47
65
  /** Network status */
48
66
  netStatus,
@@ -58,8 +76,62 @@ export const debugState: DebugState = {
58
76
  const peer = netStatus.value.peers[0];
59
77
  return peer ? peer.seq - peer.ack : null;
60
78
  }),
79
+
80
+ /** Metrics for letterboxed layout */
81
+ fps,
82
+ frameTime,
83
+ snapshotSize,
84
+ frameNumber,
85
+
86
+ /** HMR flash indicator */
87
+ hmrFlash,
61
88
  };
62
89
 
90
+ /** Cycle through layout modes: off -> letterboxed -> full -> off */
91
+ export function cycleLayout(): void {
92
+ const current = layoutMode.value;
93
+ if (current === "off") {
94
+ layoutMode.value = "letterboxed";
95
+ } else if (current === "letterboxed") {
96
+ layoutMode.value = "full";
97
+ } else {
98
+ layoutMode.value = "off";
99
+ }
100
+ }
101
+
102
+ let hmrFlashQueued = false;
103
+
104
+ /** Trigger HMR flash (only when debug UI is visible) */
105
+ export function triggerHmrFlash(): void {
106
+ if (!debugState.isVisible.value) return;
107
+
108
+ // If window doesn't have focus, queue the flash for when focus returns
109
+ if (!document.hasFocus()) {
110
+ if (!hmrFlashQueued) {
111
+ hmrFlashQueued = true;
112
+ window.addEventListener("focus", onWindowFocus);
113
+ }
114
+ return;
115
+ }
116
+
117
+ doFlash();
118
+ }
119
+
120
+ function onWindowFocus(): void {
121
+ if (hmrFlashQueued) {
122
+ hmrFlashQueued = false;
123
+ window.removeEventListener("focus", onWindowFocus);
124
+ doFlash();
125
+ }
126
+ }
127
+
128
+ function doFlash(): void {
129
+ debugState.hmrFlash.value = true;
130
+ setTimeout(() => {
131
+ debugState.hmrFlash.value = false;
132
+ }, 300);
133
+ }
134
+
63
135
  export function addLog(log: Log): void {
64
136
  debugState.logs.value = [...debugState.logs.value, log];
65
137
  }
@@ -116,7 +188,7 @@ export function clearLogs(): void {
116
188
  }
117
189
 
118
190
  export function resetState(): void {
119
- debugState.isVisible.value = false;
191
+ debugState.layoutMode.value = "off";
120
192
  debugState.logs.value = [];
121
193
  debugState.netStatus.value = {
122
194
  ourId: null,
@@ -124,4 +196,9 @@ export function resetState(): void {
124
196
  rtt: null,
125
197
  peers: [],
126
198
  };
199
+ debugState.fps.value = 0;
200
+ debugState.frameTime.value = 0;
201
+ debugState.snapshotSize.value = 0;
202
+ debugState.frameNumber.value = 0;
203
+ debugState.hmrFlash.value = false;
127
204
  }