@bloopjs/web 0.0.56 → 0.0.57

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.57",
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.57",
37
+ "@bloopjs/engine": "0.0.57",
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 } from "./debugui/state.ts";
10
11
 
11
12
  export type StartOptions = {
12
13
  /** A bloop game instance */
@@ -208,8 +209,38 @@ export class App {
208
209
  };
209
210
  window.addEventListener("keydown", playbarHotkeys);
210
211
 
212
+ // FPS calculation
213
+ let fpsFrames = 0;
214
+ let fpsLastTime = performance.now();
215
+
211
216
  const frame = () => {
212
- this.sim.step(performance.now() - this.#now);
217
+ const stepStart = performance.now();
218
+ const ticks = this.sim.step(stepStart - this.#now);
219
+
220
+ // Update debug metrics only when we actually ran simulation
221
+ if (ticks > 0) {
222
+ const stepEnd = performance.now();
223
+ debugState.frameTime.value = stepEnd - stepStart;
224
+ debugState.frameNumber.value = this.sim.time.frame;
225
+
226
+ // Measure snapshot size when debug UI is visible (letterboxed mode)
227
+ if (debugState.layoutMode.value === "letterboxed") {
228
+ const bag = this.game.bag;
229
+ if (bag) {
230
+ debugState.snapshotSize.value = JSON.stringify(bag).length;
231
+ }
232
+ }
233
+
234
+ // Calculate FPS every second
235
+ fpsFrames++;
236
+ const elapsed = stepEnd - fpsLastTime;
237
+ if (elapsed >= 1000) {
238
+ debugState.fps.value = Math.round((fpsFrames * 1000) / elapsed);
239
+ fpsFrames = 0;
240
+ fpsLastTime = stepEnd;
241
+ }
242
+ }
243
+
213
244
  if (!this.sim.isPaused) {
214
245
  try {
215
246
  this.afterFrame.notify(this.sim.time.frame);
@@ -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,93 @@ 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
+
63
+ // Left bar: frame advantage (online) or frame time % (offline)
64
+ const leftValue = isOnline ? Math.abs(advantage) : frameTime;
65
+ const leftMax = isOnline ? 10 : 16.67; // 10 frames advantage or 16.67ms budget
66
+ const leftLabel = isOnline ? "ADV" : "MS";
67
+ const leftColor = isOnline
68
+ ? advantage >= 0
69
+ ? "#4a9eff"
70
+ : "#ff4a4a"
71
+ : frameTime > 16.67
72
+ ? "#ff4a4a"
73
+ : "#4aff4a";
74
+
75
+ // Right bar: rollback depth (online) or snapshot size (offline)
76
+ // For now, we don't have rollback depth exposed, so use a placeholder
77
+ const rightValue = isOnline ? 0 : snapshotSize;
78
+ const rightMax = isOnline ? 10 : 10000; // 10 frames rollback or 10KB
79
+ const rightLabel = isOnline ? "RB" : "KB";
80
+
81
+ return (
82
+ <main className="layout-letterboxed">
83
+ <TopBar leftLabel={leftLabel} rightLabel={rightLabel} />
84
+ <VerticalBar
85
+ value={leftValue}
86
+ max={leftMax}
87
+ side="left"
88
+ color={leftColor}
89
+ />
90
+ <div className="letterboxed-game">
91
+ <GameCanvas canvas={canvas} />
92
+ </div>
93
+ <VerticalBar
94
+ value={rightValue}
95
+ max={rightMax}
96
+ side="right"
97
+ />
98
+ <BottomBar tapeUtilization={0.67} playheadPosition={0.8} isPlaying={true} />
99
+ </main>
100
+ );
101
+ }
102
+
39
103
  function GameCanvas({ canvas }: { canvas: HTMLCanvasElement }) {
40
104
  const containerRef = useRef<HTMLDivElement>(null);
41
105
 
@@ -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,20 @@ 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>;
32
40
  };
33
41
 
34
- const isVisible = signal(false);
42
+ const layoutMode = signal<LayoutMode>("off");
35
43
  const netStatus = signal<NetStatus>({
36
44
  ourId: null,
37
45
  remoteId: null,
@@ -39,10 +47,17 @@ const netStatus = signal<NetStatus>({
39
47
  peers: [],
40
48
  });
41
49
  const logs = signal<Log[]>([]);
50
+ const fps = signal(0);
51
+ const frameTime = signal(0);
52
+ const snapshotSize = signal(0);
53
+ const frameNumber = signal(0);
42
54
 
43
55
  export const debugState: DebugState = {
44
- /** Whether debug UI is visible */
45
- isVisible,
56
+ /** Layout mode: off, letterboxed, or full */
57
+ layoutMode,
58
+
59
+ /** Whether debug UI is visible (derived from layoutMode) */
60
+ isVisible: computed(() => layoutMode.value !== "off"),
46
61
 
47
62
  /** Network status */
48
63
  netStatus,
@@ -58,8 +73,26 @@ export const debugState: DebugState = {
58
73
  const peer = netStatus.value.peers[0];
59
74
  return peer ? peer.seq - peer.ack : null;
60
75
  }),
76
+
77
+ /** Metrics for letterboxed layout */
78
+ fps,
79
+ frameTime,
80
+ snapshotSize,
81
+ frameNumber,
61
82
  };
62
83
 
84
+ /** Cycle through layout modes: off -> letterboxed -> full -> off */
85
+ export function cycleLayout(): void {
86
+ const current = layoutMode.value;
87
+ if (current === "off") {
88
+ layoutMode.value = "letterboxed";
89
+ } else if (current === "letterboxed") {
90
+ layoutMode.value = "full";
91
+ } else {
92
+ layoutMode.value = "off";
93
+ }
94
+ }
95
+
63
96
  export function addLog(log: Log): void {
64
97
  debugState.logs.value = [...debugState.logs.value, log];
65
98
  }
@@ -116,7 +149,7 @@ export function clearLogs(): void {
116
149
  }
117
150
 
118
151
  export function resetState(): void {
119
- debugState.isVisible.value = false;
152
+ debugState.layoutMode.value = "off";
120
153
  debugState.logs.value = [];
121
154
  debugState.netStatus.value = {
122
155
  ourId: null,
@@ -124,4 +157,8 @@ export function resetState(): void {
124
157
  rtt: null,
125
158
  peers: [],
126
159
  };
160
+ debugState.fps.value = 0;
161
+ debugState.frameTime.value = 0;
162
+ debugState.snapshotSize.value = 0;
163
+ debugState.frameNumber.value = 0;
127
164
  }