@bloopjs/web 0.0.48 → 0.0.50

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.
Files changed (42) hide show
  1. package/dist/App.d.ts +8 -1
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/debugui/DebugUi.d.ts +22 -0
  4. package/dist/debugui/DebugUi.d.ts.map +1 -0
  5. package/dist/debugui/components/DebugToggle.d.ts +6 -0
  6. package/dist/debugui/components/DebugToggle.d.ts.map +1 -0
  7. package/dist/debugui/components/Logs.d.ts +2 -0
  8. package/dist/debugui/components/Logs.d.ts.map +1 -0
  9. package/dist/debugui/components/Root.d.ts +7 -0
  10. package/dist/debugui/components/Root.d.ts.map +1 -0
  11. package/dist/debugui/components/Stats.d.ts +2 -0
  12. package/dist/debugui/components/Stats.d.ts.map +1 -0
  13. package/dist/debugui/hooks/useAutoScroll.d.ts +6 -0
  14. package/dist/debugui/hooks/useAutoScroll.d.ts.map +1 -0
  15. package/dist/debugui/mod.d.ts +3 -0
  16. package/dist/debugui/mod.d.ts.map +1 -0
  17. package/dist/debugui/state.d.ts +33 -0
  18. package/dist/debugui/state.d.ts.map +1 -0
  19. package/dist/debugui/styles.d.ts +2 -0
  20. package/dist/debugui/styles.d.ts.map +1 -0
  21. package/dist/mod.d.ts +2 -1
  22. package/dist/mod.d.ts.map +1 -1
  23. package/dist/mod.js +1868 -13
  24. package/dist/mod.js.map +20 -6
  25. package/dist/netcode/mod.d.ts +2 -0
  26. package/dist/netcode/mod.d.ts.map +1 -1
  27. package/dist/netcode/scaffold.d.ts +13 -0
  28. package/dist/netcode/scaffold.d.ts.map +1 -0
  29. package/package.json +7 -5
  30. package/src/App.ts +41 -2
  31. package/src/debugui/DebugUi.ts +111 -0
  32. package/src/debugui/components/DebugToggle.tsx +25 -0
  33. package/src/debugui/components/Logs.tsx +57 -0
  34. package/src/debugui/components/Root.tsx +54 -0
  35. package/src/debugui/components/Stats.tsx +68 -0
  36. package/src/debugui/hooks/useAutoScroll.ts +62 -0
  37. package/src/debugui/mod.ts +2 -0
  38. package/src/debugui/state.ts +127 -0
  39. package/src/debugui/styles.ts +200 -0
  40. package/src/mod.ts +2 -1
  41. package/src/netcode/mod.ts +2 -0
  42. package/src/netcode/scaffold.ts +169 -0
@@ -2,6 +2,8 @@ export type { PeerId, BrokerMessage, PeerMessage } from "./protocol.ts";
2
2
  export type { Log, LogOpts, LogDirection, LogSeverity, OnLogCallback, } from "./logs.ts";
3
3
  export type { WebRtcPipe } from "./transport.ts";
4
4
  export type { RoomEvents } from "./broker.ts";
5
+ export type { JoinRollbackRoomOptions } from "./scaffold.ts";
5
6
  export { PacketType } from "./protocol.ts";
6
7
  export { logger } from "./logs.ts";
8
+ export { joinRollbackRoom } from "./scaffold.ts";
7
9
  //# sourceMappingURL=mod.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mod.d.ts","sourceRoot":"","sources":["../../src/netcode/mod.ts"],"names":[],"mappings":"AACA,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACxE,YAAY,EACV,GAAG,EACH,OAAO,EACP,YAAY,EACZ,WAAW,EACX,aAAa,GACd,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"mod.d.ts","sourceRoot":"","sources":["../../src/netcode/mod.ts"],"names":[],"mappings":"AACA,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACxE,YAAY,EACV,GAAG,EACH,OAAO,EACP,YAAY,EACZ,WAAW,EACX,aAAa,GACd,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,YAAY,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACnC,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,13 @@
1
+ import type { App } from "../App.ts";
2
+ export type JoinRollbackRoomOptions = {
3
+ /** Called when session becomes active */
4
+ onSessionStart?: () => void;
5
+ /** Called when session ends */
6
+ onSessionEnd?: () => void;
7
+ };
8
+ /**
9
+ * Join a rollback netcode room and wire up packet processing.
10
+ * This is a scaffold/stopgap - not the final architecture.
11
+ */
12
+ export declare function joinRollbackRoom(roomId: string, app: App, opts?: JoinRollbackRoomOptions): void;
13
+ //# sourceMappingURL=scaffold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../src/netcode/scaffold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAIrC,MAAM,MAAM,uBAAuB,GAAG;IACpC,yCAAyC;IACzC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,+BAA+B;IAC/B,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,GAAG,EACR,IAAI,CAAC,EAAE,uBAAuB,GAC7B,IAAI,CAqJN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloopjs/web",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "author": "Neil Sarkar",
5
5
  "type": "module",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "README.md"
23
23
  ],
24
24
  "scripts": {
25
- "build": "bun build src/mod.ts --outdir=dist --sourcemap=linked && bunx tsc -p publish/tsconfig.publish.json",
25
+ "build": "bun build src/mod.ts --outdir=dist --sourcemap=linked --jsx-import-source=preact --jsx-runtime=automatic && bunx tsc -p publish/tsconfig.publish.json",
26
26
  "ci:jsr": "bunx jsr publish --dry-run --allow-dirty",
27
27
  "ci:tsc": "tsc --noEmit"
28
28
  },
@@ -33,8 +33,10 @@
33
33
  "typescript": "^5"
34
34
  },
35
35
  "dependencies": {
36
- "@bloopjs/bloop": "0.0.48",
37
- "@bloopjs/engine": "0.0.48",
38
- "partysocket": "^1.1.6"
36
+ "@bloopjs/bloop": "0.0.50",
37
+ "@bloopjs/engine": "0.0.50",
38
+ "@preact/signals": "^1.3.1",
39
+ "partysocket": "^1.1.6",
40
+ "preact": "^10.25.4"
39
41
  }
40
42
  }
package/src/App.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type RoomEvents,
7
7
  } from "./netcode/broker";
8
8
  import { logger } from "./netcode/logs.ts";
9
+ import { DebugUi, type DebugUiOptions } from "./debugui/mod.ts";
9
10
 
10
11
  export type StartOptions = {
11
12
  /** A bloop game instance */
@@ -20,6 +21,8 @@ export type StartOptions = {
20
21
  startRecording?: boolean;
21
22
  /** URL for the WebRTC signaling broker (e.g. "wss://broker.example.com/ws") */
22
23
  brokerUrl?: string;
24
+ /** Enable debug UI with optional configuration */
25
+ debugUi?: boolean | DebugUiOptions;
23
26
  };
24
27
 
25
28
  const DEFAULT_BROKER_URL = "wss://webrtc-divine-glade-8064.fly.dev/ws";
@@ -35,11 +38,19 @@ export async function start(opts: StartOptions): Promise<App> {
35
38
  opts.sim = sim;
36
39
  }
37
40
 
41
+ const debugOpts = opts.debugUi
42
+ ? typeof opts.debugUi === "boolean"
43
+ ? {}
44
+ : opts.debugUi
45
+ : undefined;
46
+
38
47
  const app = new App(
39
48
  opts.sim,
40
49
  opts.game,
41
50
  opts.brokerUrl ?? DEFAULT_BROKER_URL,
51
+ debugOpts,
42
52
  );
53
+
43
54
  return app;
44
55
  }
45
56
 
@@ -59,12 +70,22 @@ export class App {
59
70
  #rafHandle: number | null = null;
60
71
  #unsubscribe: UnsubscribeFn | null = null;
61
72
  #now: number = performance.now();
62
-
63
- constructor(sim: Sim, game: Bloop<any>, brokerUrl: string) {
73
+ #debugUi: DebugUi | null = null;
74
+
75
+ constructor(
76
+ sim: Sim,
77
+ game: Bloop<any>,
78
+ brokerUrl: string,
79
+ debugUiOpts?: DebugUiOptions,
80
+ ) {
64
81
  this.#sim = sim;
65
82
  this.game = game;
66
83
  this.brokerUrl = brokerUrl;
67
84
 
85
+ if (debugUiOpts) {
86
+ this.#initDebugUi(debugUiOpts);
87
+ }
88
+
68
89
  this.game.hooks.beforeFrame = (frame: number) => {
69
90
  logger.frameNumber = this.#sim.time.frame;
70
91
  logger.matchFrame = this.#sim.wasm.get_match_frame();
@@ -83,6 +104,23 @@ export class App {
83
104
  this.#sim = sim;
84
105
  }
85
106
 
107
+ /** Initialize debug UI (creates shadow DOM and mounts Preact) */
108
+ #initDebugUi(opts: DebugUiOptions = {}): DebugUi {
109
+ if (this.#debugUi) return this.#debugUi;
110
+ this.#debugUi = new DebugUi(opts);
111
+ return this.#debugUi;
112
+ }
113
+
114
+ /** Access debug UI instance */
115
+ get debugUi(): DebugUi | null {
116
+ return this.#debugUi;
117
+ }
118
+
119
+ /** Get the canvas element from debug UI (for game rendering) */
120
+ get canvas(): HTMLCanvasElement | null {
121
+ return this.#debugUi?.canvas ?? null;
122
+ }
123
+
86
124
  /** Join a multiplayer room via the broker */
87
125
  joinRoom(roomId: string, callbacks: RoomEvents): void {
88
126
  joinRoomInternal(this.brokerUrl, roomId, callbacks);
@@ -207,6 +245,7 @@ export class App {
207
245
  this.sim.unmount();
208
246
  this.beforeFrame.unsubscribeAll();
209
247
  this.afterFrame.unsubscribeAll();
248
+ this.#debugUi?.unmount();
210
249
  }
211
250
 
212
251
  /**
@@ -0,0 +1,111 @@
1
+ import { type ComponentChild, render } from "preact";
2
+ import { Root } from "./components/Root.tsx";
3
+ import { type DebugState, debugState } from "./state.ts";
4
+ import { styles } from "./styles.ts";
5
+
6
+ export type DebugUiOptions = {
7
+ /** Hotkey to toggle debug mode (default: 'Escape') */
8
+ hotkey?: string;
9
+ /** Initial debug visibility (default: false, or true if ?debug in URL) */
10
+ initiallyVisible?: boolean;
11
+ /** Container element to mount to (default: document.body) */
12
+ container?: HTMLElement;
13
+ };
14
+
15
+ export class DebugUi {
16
+ #host: HTMLElement;
17
+ #shadow: ShadowRoot;
18
+ #canvas: HTMLCanvasElement;
19
+ #mountPoint: HTMLElement;
20
+ #cleanup: (() => void) | null = null;
21
+ #hotkey: string;
22
+
23
+ constructor(options: DebugUiOptions = {}) {
24
+ this.#hotkey = options.hotkey ?? "Escape";
25
+ const container = options.container ?? document.body;
26
+ const initiallyVisible =
27
+ options.initiallyVisible ??
28
+ new URLSearchParams(window.location.search).has("debug");
29
+
30
+ // Create host element
31
+ this.#host = document.createElement("bloop-debug-ui");
32
+ this.#host.style.cssText =
33
+ "display:block;width:100%;height:100%;position:absolute;top:0;left:0;";
34
+
35
+ // Attach shadow DOM
36
+ this.#shadow = this.#host.attachShadow({ mode: "open" });
37
+
38
+ // Inject styles
39
+ const styleEl = document.createElement("style");
40
+ styleEl.textContent = styles;
41
+ this.#shadow.appendChild(styleEl);
42
+
43
+ // Create mount point
44
+ this.#mountPoint = document.createElement("div");
45
+ this.#mountPoint.id = "debug-root";
46
+ this.#mountPoint.style.cssText = "width:100%;height:100%;";
47
+ this.#shadow.appendChild(this.#mountPoint);
48
+
49
+ // Initialize state
50
+ debugState.isVisible.value = initiallyVisible;
51
+
52
+ // Create canvas element (game renders here)
53
+ this.#canvas = document.createElement("canvas");
54
+
55
+ // Render Preact app
56
+ this.#render();
57
+
58
+ // Append to container
59
+ container.appendChild(this.#host);
60
+
61
+ // Set up hotkey listener
62
+ this.#cleanup = this.#setupHotkey();
63
+
64
+ // Re-render when isVisible changes
65
+ debugState.isVisible.subscribe(() => {
66
+ this.#render();
67
+ });
68
+ }
69
+
70
+ #render(): void {
71
+ render(
72
+ Root({ canvas: this.#canvas, hotkey: this.#hotkey }) as ComponentChild,
73
+ this.#mountPoint,
74
+ );
75
+ }
76
+
77
+ #setupHotkey(): () => void {
78
+ const handler = (e: KeyboardEvent) => {
79
+ if (e.key === this.#hotkey) {
80
+ debugState.isVisible.value = !debugState.isVisible.value;
81
+ }
82
+ };
83
+ window.addEventListener("keydown", handler);
84
+ return () => window.removeEventListener("keydown", handler);
85
+ }
86
+
87
+ /** The canvas element for game rendering */
88
+ get canvas(): HTMLCanvasElement {
89
+ return this.#canvas;
90
+ }
91
+
92
+ /** Access to state signals for external updates */
93
+ get state(): DebugState {
94
+ return debugState;
95
+ }
96
+
97
+ /** Whether the debug panel is currently visible */
98
+ get isVisible(): boolean {
99
+ return debugState.isVisible.value;
100
+ }
101
+
102
+ set isVisible(value: boolean) {
103
+ debugState.isVisible.value = value;
104
+ }
105
+
106
+ unmount(): void {
107
+ this.#cleanup?.();
108
+ render(null, this.#mountPoint);
109
+ this.#host.remove();
110
+ }
111
+ }
@@ -0,0 +1,25 @@
1
+ import { debugState } from "../state.ts";
2
+
3
+ type DebugToggleProps = {
4
+ hotkey?: string;
5
+ };
6
+
7
+ export function DebugToggle({ hotkey = "Escape" }: DebugToggleProps) {
8
+ const isVisible = debugState.isVisible.value;
9
+
10
+ const toggle = () => {
11
+ debugState.isVisible.value = !debugState.isVisible.value;
12
+ };
13
+
14
+ return (
15
+ <button
16
+ className="debug-toggle"
17
+ onClick={toggle}
18
+ onMouseDown={(e) => e.stopPropagation()}
19
+ onMouseUp={(e) => e.stopPropagation()}
20
+ title={isVisible ? `Hide debug (${hotkey})` : `Show debug (${hotkey})`}
21
+ >
22
+ {isVisible ? "\u2715" : "\u2699"}
23
+ </button>
24
+ );
25
+ }
@@ -0,0 +1,57 @@
1
+ import { useSignalEffect } from "@preact/signals";
2
+ import { debugState } from "../state.ts";
3
+ import { useAutoScroll } from "../hooks/useAutoScroll.ts";
4
+ import type { Log } from "../../netcode/logs.ts";
5
+
6
+ function formatTimestamp(ms: number): string {
7
+ const date = new Date(ms);
8
+ const hours = date.getHours() % 12;
9
+ const minutes = date.getMinutes().toString().padStart(2, "0");
10
+ const seconds = date.getSeconds().toString().padStart(2, "0");
11
+ const millis = date.getMilliseconds().toString().padStart(3, "0");
12
+ return `${hours}:${minutes}:${seconds}.${millis}`;
13
+ }
14
+
15
+ export function Logs() {
16
+ const { containerRef, onContentUpdated } = useAutoScroll(80);
17
+ const logs = debugState.logs.value;
18
+
19
+ // Trigger auto-scroll when logs change
20
+ useSignalEffect(() => {
21
+ const _ = debugState.logs.value.length;
22
+ onContentUpdated();
23
+ });
24
+
25
+ return (
26
+ <ul className="logs-list" ref={containerRef}>
27
+ {logs.map((log: Log, index: number) => (
28
+ <LogEntry key={index} log={log} />
29
+ ))}
30
+ </ul>
31
+ );
32
+ }
33
+
34
+ function LogEntry({ log }: { log: Log }) {
35
+ return (
36
+ <li className={`log ${log.source}`}>
37
+ <div className="contents">
38
+ <h3 className={log.source}>
39
+ <span className="source">{log.source} | </span>
40
+ {log.match_frame != null ? (
41
+ <span className="frame-number">m{log.match_frame} | </span>
42
+ ) : (
43
+ <span className="frame-number">f{log.frame_number} | </span>
44
+ )}
45
+ <span className="timestamp">{formatTimestamp(log.timestamp)}</span>
46
+ </h3>
47
+ <div className="content">
48
+ {log.label && <p>{log.label}</p>}
49
+ {log.json && (
50
+ <pre className="json">{JSON.stringify(log.json, null, 2)}</pre>
51
+ )}
52
+ {log.packet && <div>{log.packet.size} bytes</div>}
53
+ </div>
54
+ </div>
55
+ </li>
56
+ );
57
+ }
@@ -0,0 +1,54 @@
1
+ import { useRef, useEffect } from "preact/hooks";
2
+ import { debugState } from "../state.ts";
3
+ import { Stats } from "./Stats.tsx";
4
+ import { Logs } from "./Logs.tsx";
5
+ import { DebugToggle } from "./DebugToggle.tsx";
6
+
7
+ type RootProps = {
8
+ canvas: HTMLCanvasElement;
9
+ hotkey?: string;
10
+ };
11
+
12
+ export function Root({ canvas, hotkey = "Escape" }: RootProps) {
13
+ const isVisible = debugState.isVisible.value;
14
+
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
+ ) : (
30
+ <main className="fullscreen">
31
+ <GameCanvas canvas={canvas} />
32
+ </main>
33
+ )}
34
+ <DebugToggle hotkey={hotkey} />
35
+ </>
36
+ );
37
+ }
38
+
39
+ function GameCanvas({ canvas }: { canvas: HTMLCanvasElement }) {
40
+ const containerRef = useRef<HTMLDivElement>(null);
41
+
42
+ useEffect(() => {
43
+ const container = containerRef.current;
44
+ if (container && !container.contains(canvas)) {
45
+ container.appendChild(canvas);
46
+ }
47
+
48
+ return () => {
49
+ // Don't remove canvas on cleanup - it may need to persist
50
+ };
51
+ }, [canvas]);
52
+
53
+ return <div className="canvas-container" ref={containerRef} />;
54
+ }
@@ -0,0 +1,68 @@
1
+ import { useSignal, useSignalEffect } from "@preact/signals";
2
+ import { debugState } from "../state.ts";
3
+
4
+ export function Stats() {
5
+ const peer = debugState.peer.value;
6
+ const netStatus = debugState.netStatus.value;
7
+
8
+ // Live-updating "time since last packet"
9
+ const lastPacketTime = useSignal<string | null>(null);
10
+
11
+ useSignalEffect(() => {
12
+ const p = debugState.peer.value;
13
+ if (!p) {
14
+ lastPacketTime.value = null;
15
+ return;
16
+ }
17
+
18
+ const update = () => {
19
+ lastPacketTime.value = (performance.now() - p.lastPacketTime).toFixed(0);
20
+ };
21
+ update();
22
+ const id = setInterval(update, 100);
23
+ return () => clearInterval(id);
24
+ });
25
+
26
+ if (!peer) {
27
+ return (
28
+ <div className="stats-panel">
29
+ <h3>Network Stats</h3>
30
+ <p>No peer connected</p>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div className="stats-panel">
37
+ <h3>Network Stats - {peer.nickname}</h3>
38
+ <table>
39
+ <tbody>
40
+ <tr>
41
+ <td>Our Peer ID</td>
42
+ <td>{netStatus.ourId}</td>
43
+ </tr>
44
+ <tr>
45
+ <td>Remote Peer ID</td>
46
+ <td>{netStatus.remoteId}</td>
47
+ </tr>
48
+ <tr>
49
+ <td>Advantage</td>
50
+ <td>{peer.seq - peer.ack}</td>
51
+ </tr>
52
+ <tr>
53
+ <td>Current Seq</td>
54
+ <td>{peer.seq}</td>
55
+ </tr>
56
+ <tr>
57
+ <td>Current Ack</td>
58
+ <td>{peer.ack}</td>
59
+ </tr>
60
+ <tr>
61
+ <td>Time since last packet</td>
62
+ <td>{lastPacketTime.value}ms</td>
63
+ </tr>
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,62 @@
1
+ import { useRef, useCallback, useEffect } from "preact/hooks";
2
+
3
+ export function useAutoScroll(threshold = 80) {
4
+ const containerRef = useRef<HTMLElement | null>(null);
5
+ const autoScrollRef = useRef(true);
6
+
7
+ const onScroll = useCallback(() => {
8
+ const el = containerRef.current;
9
+ if (!el) return;
10
+
11
+ const distanceFromBottom =
12
+ el.scrollHeight - el.scrollTop - el.clientHeight;
13
+ autoScrollRef.current = distanceFromBottom < threshold;
14
+ }, [threshold]);
15
+
16
+ const scrollToBottom = useCallback(() => {
17
+ const el = containerRef.current;
18
+ if (!el) return;
19
+
20
+ // Use requestAnimationFrame for DOM update timing
21
+ requestAnimationFrame(() => {
22
+ el.scrollTop = el.scrollHeight;
23
+ });
24
+ }, []);
25
+
26
+ const onContentUpdated = useCallback(() => {
27
+ if (autoScrollRef.current) {
28
+ scrollToBottom();
29
+ }
30
+ }, [scrollToBottom]);
31
+
32
+ // Set up scroll listener when ref changes
33
+ useEffect(() => {
34
+ const el = containerRef.current;
35
+ if (el) {
36
+ el.addEventListener("scroll", onScroll);
37
+ return () => el.removeEventListener("scroll", onScroll);
38
+ }
39
+ }, [onScroll]);
40
+
41
+ // Return ref callback to set containerRef
42
+ const setRef = useCallback(
43
+ (el: HTMLElement | null) => {
44
+ // Clean up old listener
45
+ if (containerRef.current) {
46
+ containerRef.current.removeEventListener("scroll", onScroll);
47
+ }
48
+ containerRef.current = el;
49
+ // Add new listener
50
+ if (el) {
51
+ el.addEventListener("scroll", onScroll);
52
+ }
53
+ },
54
+ [onScroll],
55
+ );
56
+
57
+ return {
58
+ containerRef: setRef,
59
+ onContentUpdated,
60
+ scrollToBottom,
61
+ };
62
+ }
@@ -0,0 +1,2 @@
1
+ export { DebugUi, type DebugUiOptions } from "./DebugUi.ts";
2
+ export * from "./state.ts";
@@ -0,0 +1,127 @@
1
+ import {
2
+ computed,
3
+ type ReadonlySignal,
4
+ type Signal,
5
+ signal,
6
+ } from "@preact/signals";
7
+ import type { Log } from "../netcode/logs.ts";
8
+
9
+ export type FrameNumber = number;
10
+
11
+ export type Peer = {
12
+ id: string;
13
+ nickname: string;
14
+ ack: FrameNumber;
15
+ seq: FrameNumber;
16
+ lastPacketTime: number;
17
+ };
18
+
19
+ export type NetStatus = {
20
+ ourId: number | null;
21
+ remoteId: number | null;
22
+ rtt: number | null;
23
+ peers: Peer[];
24
+ };
25
+
26
+ export type DebugState = {
27
+ isVisible: Signal<boolean>;
28
+ netStatus: Signal<NetStatus>;
29
+ logs: Signal<Log[]>;
30
+ peer: ReadonlySignal<Peer | null>;
31
+ advantage: ReadonlySignal<number | null>;
32
+ };
33
+
34
+ const isVisible = signal(false);
35
+ const netStatus = signal<NetStatus>({
36
+ ourId: null,
37
+ remoteId: null,
38
+ rtt: null,
39
+ peers: [],
40
+ });
41
+ const logs = signal<Log[]>([]);
42
+
43
+ export const debugState: DebugState = {
44
+ /** Whether debug UI is visible */
45
+ isVisible,
46
+
47
+ /** Network status */
48
+ netStatus,
49
+
50
+ /** Log entries */
51
+ logs,
52
+
53
+ /** First connected peer (for Stats panel) */
54
+ peer: computed(() => netStatus.value.peers[0] ?? null),
55
+
56
+ /** Advantage calculation (seq - ack) */
57
+ advantage: computed(() => {
58
+ const peer = netStatus.value.peers[0];
59
+ return peer ? peer.seq - peer.ack : null;
60
+ }),
61
+ };
62
+
63
+ export function addLog(log: Log): void {
64
+ debugState.logs.value = [...debugState.logs.value, log];
65
+ }
66
+
67
+ export function updatePeer(id: string, updates: Partial<Peer>): void {
68
+ const peers = [...debugState.netStatus.value.peers];
69
+ const idx = peers.findIndex((p) => p.id === id);
70
+ const existing = peers[idx];
71
+ if (idx >= 0 && existing) {
72
+ peers[idx] = {
73
+ id: updates.id ?? existing.id,
74
+ nickname: updates.nickname ?? existing.nickname,
75
+ ack: updates.ack ?? existing.ack,
76
+ seq: updates.seq ?? existing.seq,
77
+ lastPacketTime: updates.lastPacketTime ?? existing.lastPacketTime,
78
+ };
79
+ debugState.netStatus.value = {
80
+ ...debugState.netStatus.value,
81
+ peers,
82
+ };
83
+ }
84
+ }
85
+
86
+ export function addPeer(peer: Peer): void {
87
+ debugState.netStatus.value = {
88
+ ...debugState.netStatus.value,
89
+ peers: [...debugState.netStatus.value.peers, peer],
90
+ };
91
+ }
92
+
93
+ export function removePeer(id: string): void {
94
+ debugState.netStatus.value = {
95
+ ...debugState.netStatus.value,
96
+ peers: debugState.netStatus.value.peers.filter((p) => p.id !== id),
97
+ };
98
+ }
99
+
100
+ export function setLocalId(id: number): void {
101
+ debugState.netStatus.value = {
102
+ ...debugState.netStatus.value,
103
+ ourId: id,
104
+ };
105
+ }
106
+
107
+ export function setRemoteId(id: number): void {
108
+ debugState.netStatus.value = {
109
+ ...debugState.netStatus.value,
110
+ remoteId: id,
111
+ };
112
+ }
113
+
114
+ export function clearLogs(): void {
115
+ debugState.logs.value = [];
116
+ }
117
+
118
+ export function resetState(): void {
119
+ debugState.isVisible.value = false;
120
+ debugState.logs.value = [];
121
+ debugState.netStatus.value = {
122
+ ourId: null,
123
+ remoteId: null,
124
+ rtt: null,
125
+ peers: [],
126
+ };
127
+ }