@bloopjs/bloop 0.0.13 → 0.0.14

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/src/runtime.ts ADDED
@@ -0,0 +1,273 @@
1
+ import {
2
+ DEFAULT_WASM_URL,
3
+ type EnginePointer,
4
+ type Key,
5
+ keyToKeyCode,
6
+ type MouseButton,
7
+ mouseButtonToMouseButtonCode,
8
+ SNAPSHOT_HEADER_ENGINE_LEN_OFFSET,
9
+ SNAPSHOT_HEADER_LEN,
10
+ SNAPSHOT_HEADER_USER_LEN_OFFSET,
11
+ TimeContext,
12
+ type WasmEngine,
13
+ } from "@bloopjs/engine";
14
+ import { assert } from "./util";
15
+
16
+ export type MountOpts = {
17
+ wasmUrl?: URL;
18
+ /**
19
+ * A callback function to run logic for a given frame
20
+ */
21
+ systemsCallback: (system_handle: number, ptr: EnginePointer) => void;
22
+
23
+ /**
24
+ * Sets buffer to the latest engine memory buffer
25
+ *
26
+ * Note that if the engine wasm memory grows, all dataviews into the memory must be updated
27
+ */
28
+ setBuffer: (buffer: ArrayBuffer) => void;
29
+
30
+ /**
31
+ * Whether to start recording immediately upon mount
32
+ * Defaults to true
33
+ */
34
+ startRecording?: boolean;
35
+
36
+ /**
37
+ * Optional hook to serialize some data when snapshotting
38
+ */
39
+ serialize?: SerializeFn;
40
+
41
+ /**
42
+ * Optional hook to deserialize some data when restoring
43
+ */
44
+ deserialize?: DeserializeFn;
45
+ };
46
+
47
+ export type SerializeFn = () => {
48
+ size: number;
49
+ write(buffer: ArrayBufferLike, ptr: EnginePointer): void;
50
+ };
51
+
52
+ export type DeserializeFn = (
53
+ buffer: ArrayBufferLike,
54
+ ptr: EnginePointer,
55
+ size: number,
56
+ ) => void;
57
+
58
+ /**
59
+ * The runtime is a portable runtime that is responsible for:
60
+ *
61
+ * * Moving the engine forward and backward in time
62
+ * * Maintaining a js-friendly view of engine memory
63
+ */
64
+ export class Runtime {
65
+ wasm: WasmEngine;
66
+ #memory: WebAssembly.Memory;
67
+ #time: TimeContext;
68
+ #serialize?: SerializeFn;
69
+ constructor(
70
+ wasm: WasmEngine,
71
+ memory: WebAssembly.Memory,
72
+ opts?: { serialize?: SerializeFn },
73
+ ) {
74
+ this.wasm = wasm;
75
+ this.#memory = memory;
76
+ this.#time = new TimeContext(
77
+ new DataView(this.#memory.buffer, this.wasm.get_time_ctx()),
78
+ );
79
+ this.#serialize = opts?.serialize;
80
+ }
81
+
82
+ step(ms?: number) {
83
+ this.wasm.step(ms ?? 16);
84
+ }
85
+
86
+ stepBack() {
87
+ if (this.time.frame === 0) {
88
+ return;
89
+ }
90
+ this.seek(this.time.frame - 1);
91
+ }
92
+
93
+ seek(frame: number) {
94
+ assert(
95
+ this.hasHistory,
96
+ "Not recording or playing back, can't seek to frame",
97
+ );
98
+ this.wasm.seek(frame);
99
+ }
100
+
101
+ record() {
102
+ const serializer = this.#serialize ? this.#serialize() : null;
103
+ const size = serializer ? serializer.size : 0;
104
+ this.wasm.start_recording(size, 1024);
105
+ }
106
+
107
+ snapshot(): Uint8Array<ArrayBuffer> {
108
+ const serializer = this.#serialize ? this.#serialize() : null;
109
+ const size = serializer ? serializer.size : 0;
110
+
111
+ const ptr = this.wasm.take_snapshot(size);
112
+ const header = new Uint32Array(this.#memory.buffer, ptr, 4);
113
+ const userLenIndex =
114
+ SNAPSHOT_HEADER_USER_LEN_OFFSET / Uint32Array.BYTES_PER_ELEMENT;
115
+ const engineLenIndex =
116
+ SNAPSHOT_HEADER_ENGINE_LEN_OFFSET / Uint32Array.BYTES_PER_ELEMENT;
117
+ assert(header[userLenIndex], `header user length missing`);
118
+ assert(header[engineLenIndex], `header engine length missing`);
119
+ const length = header[userLenIndex] + header[engineLenIndex];
120
+ const memoryView = new Uint8Array(this.#memory.buffer, ptr, length);
121
+
122
+ const copy = new Uint8Array(length);
123
+ copy.set(memoryView);
124
+
125
+ return copy;
126
+ }
127
+
128
+ restore(snapshot: Uint8Array) {
129
+ const dataPtr = this.wasm.alloc(snapshot.byteLength);
130
+ assert(
131
+ dataPtr > 0,
132
+ `failed to allocate ${snapshot.byteLength} bytes for snapshot restore, pointer=${dataPtr}`,
133
+ );
134
+
135
+ // copy snapshot into wasm memory
136
+ const memoryView = new Uint8Array(
137
+ this.#memory.buffer,
138
+ dataPtr,
139
+ snapshot.byteLength,
140
+ );
141
+ memoryView.set(snapshot);
142
+
143
+ // restore the snapshot
144
+ this.wasm.restore(dataPtr);
145
+
146
+ // free the allocated memory
147
+ this.wasm.free(dataPtr, snapshot.byteLength);
148
+ }
149
+
150
+ get time(): TimeContext {
151
+ if (
152
+ !this.#time.dataView ||
153
+ this.#time.dataView.buffer !== this.#memory.buffer
154
+ ) {
155
+ // update the data view to the latest memory buffer
156
+ this.#time.dataView = new DataView(
157
+ this.#memory.buffer,
158
+ this.wasm.get_time_ctx(),
159
+ );
160
+ }
161
+ return this.#time;
162
+ }
163
+
164
+ get buffer(): ArrayBuffer {
165
+ return this.#memory.buffer;
166
+ }
167
+
168
+ get isRecording(): boolean {
169
+ return this.wasm.is_recording();
170
+ }
171
+
172
+ get isReplaying(): boolean {
173
+ return this.wasm.is_replaying();
174
+ }
175
+
176
+ get hasHistory(): boolean {
177
+ return this.isRecording || this.isReplaying;
178
+ }
179
+
180
+ emit = {
181
+ keydown: (key: Key): void => {
182
+ this.wasm.emit_keydown(keyToKeyCode(key));
183
+ },
184
+ keyup: (key: Key): void => {
185
+ this.wasm.emit_keyup(keyToKeyCode(key));
186
+ },
187
+ mousemove: (x: number, y: number): void => {
188
+ this.wasm.emit_mousemove(x, y);
189
+ },
190
+ mousedown: (button: MouseButton): void => {
191
+ this.wasm.emit_mousedown(mouseButtonToMouseButtonCode(button));
192
+ },
193
+ mouseup: (button: MouseButton): void => {
194
+ this.wasm.emit_mouseup(mouseButtonToMouseButtonCode(button));
195
+ },
196
+ mousewheel: (x: number, y: number): void => {
197
+ this.wasm.emit_mousewheel(x, y);
198
+ },
199
+ };
200
+ }
201
+
202
+ export type MountResult = {
203
+ runtime: Runtime;
204
+ wasm: WasmEngine;
205
+ };
206
+
207
+ export async function mount(opts: MountOpts): Promise<MountResult> {
208
+ if (
209
+ (opts.serialize && !opts.deserialize) ||
210
+ (!opts.serialize && opts.deserialize)
211
+ ) {
212
+ throw new Error("Snapshot and restore hooks must be provided together");
213
+ }
214
+
215
+ // https://github.com/oven-sh/bun/issues/12434
216
+ const bytes = await Bun.file(opts.wasmUrl ?? DEFAULT_WASM_URL).arrayBuffer();
217
+
218
+ // 1mb to 64mb
219
+ // use bun check:wasm to find initial memory page size
220
+ const memory = new WebAssembly.Memory({ initial: 17, maximum: 1000 });
221
+ const wasmInstantiatedSource = await WebAssembly.instantiate(bytes, {
222
+ env: {
223
+ memory,
224
+ __cb: function (system_handle: number, ptr: number) {
225
+ opts.setBuffer(memory.buffer);
226
+ opts.systemsCallback(system_handle, ptr);
227
+ },
228
+ console_log: function (ptr: EnginePointer, len: number) {
229
+ const bytes = new Uint8Array(memory.buffer, ptr, len);
230
+ const string = new TextDecoder("utf-8").decode(bytes);
231
+ console.log(string);
232
+ },
233
+ user_data_len: function () {
234
+ const serializer = opts.serialize ? opts.serialize() : null;
235
+ return serializer ? serializer.size : 0;
236
+ },
237
+ user_data_serialize: function (ptr: EnginePointer, len: number) {
238
+ if (!opts.serialize) {
239
+ return;
240
+ }
241
+ const serializer = opts.serialize();
242
+ if (len !== serializer.size) {
243
+ throw new Error(
244
+ `user_data_write length mismatch, expected=${serializer.size} got=${len}`,
245
+ );
246
+ }
247
+ serializer.write(memory.buffer, ptr);
248
+ },
249
+ user_data_deserialize: function (ptr: EnginePointer, len: number) {
250
+ if (!opts.deserialize) {
251
+ return;
252
+ }
253
+ opts.deserialize(memory.buffer, ptr, len);
254
+ },
255
+ },
256
+ });
257
+ const wasm = wasmInstantiatedSource.instance.exports as WasmEngine;
258
+
259
+ wasm.initialize();
260
+
261
+ const runtime = new Runtime(wasm, memory, {
262
+ serialize: opts.serialize,
263
+ });
264
+
265
+ if (opts.startRecording ?? true) {
266
+ runtime.record();
267
+ }
268
+
269
+ return {
270
+ runtime,
271
+ wasm,
272
+ };
273
+ }
package/src/system.ts CHANGED
@@ -1,11 +1,11 @@
1
+ import type { Context } from "./context";
2
+ import type { BloopSchema } from "./data/schema";
1
3
  import type {
2
4
  KeyEvent,
3
5
  MouseButtonEvent,
4
- MousePositionEvent,
6
+ MouseMoveEvent,
5
7
  MouseWheelEvent,
6
- } from "@bloopjs/engine";
7
- import type { Context } from "./context";
8
- import type { BloopSchema } from "./data/schema";
8
+ } from "./events";
9
9
 
10
10
  export type System<GS extends BloopSchema = BloopSchema> = {
11
11
  label?: string;
@@ -38,7 +38,7 @@ export type System<GS extends BloopSchema = BloopSchema> = {
38
38
 
39
39
  mousemove?: (
40
40
  context: Context<GS> & {
41
- event: MousePositionEvent;
41
+ event: MouseMoveEvent;
42
42
  },
43
43
  ) => void;
44
44
 
package/src/util.ts ADDED
@@ -0,0 +1,23 @@
1
+ export function toHexString(dataView: DataView, length?: number): string {
2
+ length ??= dataView.byteLength;
3
+ let hexString = "";
4
+ for (let i = 0; i < length; i++) {
5
+ const byte = dataView.getUint8(i);
6
+ hexString += `${byte.toString(16).padStart(2, "0")} `;
7
+ }
8
+ return hexString.trim();
9
+ }
10
+
11
+ export function assert(condition: any, message?: string): asserts condition {
12
+ if (condition == null || condition === false) {
13
+ throw new Error(message ?? "Assertion failed");
14
+ }
15
+ }
16
+
17
+ export function unwrap<T>(
18
+ value: T | null | undefined,
19
+ message?: string,
20
+ ): NonNullable<T> {
21
+ assert(value != null, message ?? `Unwrap failed: value is ${value}`);
22
+ return value;
23
+ }
@@ -1,8 +1,9 @@
1
- import { it, expect, describe } from "bun:test";
2
- import { mount, type Key, type MouseButton } from "@bloopjs/engine";
3
- import { Bloop } from "../src/mod";
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { Key, MouseButton } from "@bloopjs/engine";
3
+ import { Bloop } from "../src/bloop";
4
+ import { mount } from "../src/runtime";
4
5
 
5
- describe("loop", () => {
6
+ describe("inputs", () => {
6
7
  it("runs a single system", async () => {
7
8
  const bloop = Bloop.create();
8
9
  let count = 0;
@@ -19,7 +20,26 @@ describe("loop", () => {
19
20
  expect(count).toEqual(1);
20
21
  });
21
22
 
22
- it("passes through input events", async () => {
23
+ it("routes keydown event", async () => {
24
+ const bloop = Bloop.create();
25
+ let receivedKey: Key | null = null;
26
+ let called = false;
27
+ bloop.system("input", {
28
+ keydown({ event }) {
29
+ receivedKey = event.key;
30
+ called = true;
31
+ },
32
+ });
33
+
34
+ const { runtime } = await mount(bloop);
35
+ runtime.emit.keydown("Space");
36
+ runtime.step();
37
+
38
+ expect(called).toBe(true);
39
+ expect(receivedKey!).toEqual("Space");
40
+ });
41
+
42
+ it("routes all input events", async () => {
23
43
  const bloop = Bloop.create();
24
44
 
25
45
  const events = {
@@ -53,12 +73,12 @@ describe("loop", () => {
53
73
  },
54
74
  });
55
75
 
56
- const { runtime, emitter } = await mount(bloop);
76
+ const { runtime } = await mount(bloop);
57
77
 
58
- emitter.keydown("Space");
59
- emitter.mousemove(100, 150);
60
- emitter.mousedown("Left");
61
- emitter.mousewheel(1, 2);
78
+ runtime.emit.keydown("Space");
79
+ runtime.emit.mousedown("Left");
80
+ runtime.emit.mousemove(100, 150);
81
+ runtime.emit.mousewheel(1, 2);
62
82
 
63
83
  runtime.step();
64
84
 
@@ -67,9 +87,9 @@ describe("loop", () => {
67
87
  expect(events.mousedown).toEqual("Left");
68
88
  expect(events.mousewheel).toEqual({ x: 1, y: 2 });
69
89
 
70
- emitter.keyup("Space");
71
- emitter.mouseup("Left");
72
- emitter.mousemove(3, 4);
90
+ runtime.emit.keyup("Space");
91
+ runtime.emit.mouseup("Left");
92
+ runtime.emit.mousemove(3, 4);
73
93
 
74
94
  runtime.step();
75
95
 
@@ -78,82 +98,127 @@ describe("loop", () => {
78
98
  expect(events.mousemove).toEqual({ x: 3, y: 4 });
79
99
  });
80
100
 
81
- it("keeps track of keyboard and mouse snapshots", async () => {
101
+ it("exposes keyboard context", async () => {
82
102
  const bloop = Bloop.create({
83
103
  bag: {
84
- cool: "nice",
104
+ down: null as boolean | null,
105
+ held: null as boolean | null,
106
+ up: null as boolean | null,
85
107
  },
86
108
  });
87
109
 
88
- const events = {
89
- keydown: null as boolean | null,
90
- keyheld: null as boolean | null,
91
- keyup: null as boolean | null,
92
- mouseheld: null as boolean | null,
93
- mousedown: null as boolean | null,
94
- mouseup: null as boolean | null,
95
- };
96
-
97
- bloop.system("input snapshots", {
98
- update({ inputs }) {
99
- events.keydown = inputs.keys.space.down;
100
- events.keyheld = inputs.keys.space.held;
101
- events.keyup = inputs.keys.space.up;
102
-
103
- events.mousedown = inputs.mouse.left.down;
104
- events.mouseheld = inputs.mouse.left.held;
105
- events.mouseup = inputs.mouse.left.up;
110
+ bloop.system("key state", {
111
+ update({ inputs, bag }) {
112
+ bag.down = inputs.keys.backquote.down;
113
+ bag.held = inputs.keys.backquote.held;
114
+ bag.up = inputs.keys.backquote.up;
106
115
  },
107
116
  });
108
117
 
109
- const { runtime, emitter } = await mount(bloop);
118
+ const { runtime } = await mount(bloop);
110
119
 
111
120
  // Initial state
112
121
  runtime.step();
113
- expect(events).toEqual({
114
- keydown: false,
115
- keyheld: false,
116
- keyup: false,
117
- mousedown: false,
118
- mouseheld: false,
119
- mouseup: false,
122
+ expect(bloop.bag).toEqual({
123
+ down: false,
124
+ held: false,
125
+ up: false,
120
126
  });
121
127
 
122
128
  // down and held are both true on the first frame of a key down
123
- emitter.keydown("Space");
124
- emitter.mousedown("Left");
129
+ runtime.emit.keydown("Backquote");
125
130
  runtime.step();
126
- expect(events).toEqual({
127
- keydown: true,
128
- keyheld: true,
129
- keyup: false,
130
- mousedown: true,
131
- mouseheld: true,
132
- mouseup: false,
131
+ expect(bloop.bag).toEqual({
132
+ down: true,
133
+ held: true,
134
+ up: false,
133
135
  });
134
136
 
135
137
  // held remains true, down goes false
136
138
  runtime.step();
137
- expect(events).toEqual({
138
- keydown: false,
139
- keyheld: true,
140
- keyup: false,
141
- mousedown: false,
142
- mouseheld: true,
143
- mouseup: false,
139
+ expect(bloop.bag).toEqual({
140
+ down: false,
141
+ held: true,
142
+ up: false,
144
143
  });
145
144
 
146
145
  // on key up, up is true, held and down are false
147
- emitter.keyup("Space");
148
- emitter.mouseup("Left");
146
+ runtime.emit.keyup("Backquote");
147
+ runtime.step();
148
+ expect(bloop.bag).toEqual({
149
+ down: false,
150
+ held: false,
151
+ up: true,
152
+ });
153
+ });
154
+
155
+ it("exposes mouse context", async () => {
156
+ const bloop = Bloop.create({
157
+ bag: {
158
+ down: null as boolean | null,
159
+ held: null as boolean | null,
160
+ up: null as boolean | null,
161
+ position: null as { x: number; y: number } | null,
162
+ wheel: null as { x: number; y: number } | null,
163
+ },
164
+ });
165
+
166
+ bloop.system("mouse state", {
167
+ update({ inputs, bag }) {
168
+ bag.down = inputs.mouse.left.down;
169
+ bag.held = inputs.mouse.left.held;
170
+ bag.up = inputs.mouse.left.up;
171
+ bag.position = { x: inputs.mouse.x, y: inputs.mouse.y };
172
+ bag.wheel = inputs.mouse.wheel;
173
+ },
174
+ });
175
+
176
+ const { runtime } = await mount(bloop);
177
+
178
+ // Initial state
179
+ runtime.step();
180
+ expect(bloop.bag).toEqual({
181
+ down: false,
182
+ held: false,
183
+ up: false,
184
+ position: { x: 0, y: 0 },
185
+ wheel: { x: 0, y: 0 },
186
+ });
187
+
188
+ // down and held are both true on the first frame of a key down
189
+ runtime.emit.mousedown("Left");
190
+ runtime.step();
191
+ expect(bloop.bag).toMatchObject({
192
+ down: true,
193
+ held: true,
194
+ up: false,
195
+ });
196
+
197
+ runtime.emit.mousemove(123, 456);
198
+ runtime.step();
199
+ expect(bloop.bag).toMatchObject({
200
+ down: false,
201
+ held: true,
202
+ up: false,
203
+ position: { x: 123, y: 456 },
204
+ });
205
+
206
+ runtime.emit.mousewheel(5, -3);
207
+ runtime.step();
208
+ expect(bloop.bag).toMatchObject({
209
+ down: false,
210
+ held: true,
211
+ up: false,
212
+ position: { x: 123, y: 456 },
213
+ wheel: { x: 5, y: -3 },
214
+ });
215
+
216
+ runtime.emit.mouseup("Left");
149
217
  runtime.step();
150
- expect(events).toEqual({
151
- keydown: false,
152
- keyheld: false,
153
- keyup: true,
154
- mousedown: false,
155
- mouseheld: false,
156
- mouseup: true,
218
+ expect(bloop.bag).toMatchObject({
219
+ down: false,
220
+ held: false,
221
+ up: true,
157
222
  });
158
223
  });
159
224