@bloopjs/web 0.0.60 → 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/dist/App.d.ts +3 -1
- package/dist/App.d.ts.map +1 -1
- package/dist/debugui/components/BottomBar.d.ts +1 -7
- package/dist/debugui/components/BottomBar.d.ts.map +1 -1
- package/dist/debugui/state.d.ts +16 -0
- package/dist/debugui/state.d.ts.map +1 -1
- package/dist/debugui/styles.d.ts +1 -1
- package/dist/debugui/styles.d.ts.map +1 -1
- package/dist/mod.js +2897 -2658
- package/dist/mod.js.map +14 -14
- package/package.json +3 -3
- package/src/App.ts +93 -16
- package/src/debugui/components/BottomBar.tsx +120 -23
- package/src/debugui/components/Root.tsx +1 -1
- package/src/debugui/state.ts +108 -0
- package/src/debugui/styles.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloopjs/web",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
37
|
-
"@bloopjs/engine": "0.0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
151
|
+
<div className="seek-bar" {...seekDrag}>
|
|
55
152
|
<div
|
|
56
153
|
className="seek-bar-fill"
|
|
57
154
|
style={{ width: `${tapeUtilization * 100}%` }}
|
package/src/debugui/state.ts
CHANGED
|
@@ -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
|
}
|