game-koi 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -50,6 +50,33 @@ the page:
50
50
  binding them would be confidently wrong rather than merely absent. Use
51
51
  `gamepadMap: null` and your own polling for such a pad.
52
52
 
53
+ ### Button events
54
+
55
+ `koi.on("press" | "release", listener)` reports button edges from every input path —
56
+ the built-in keyboard and gamepad handling as well as your own `press`/`release` calls —
57
+ so an on-screen joypad can mirror whatever is driving the emulator:
58
+
59
+ ```ts
60
+ const stop = koi.on("press", ({ button, source, pressed }) => {
61
+ // button: the one that changed. source: "keyboard" | "gamepad" | "api".
62
+ // pressed: everything held after this edge, as a Set — render straight from it.
63
+ render(pressed);
64
+ });
65
+ koi.on("release", ({ pressed }) => render(pressed));
66
+
67
+ stop(); // or koi.off("press", listener)
68
+ ```
69
+
70
+ Only *changes* are reported. A key held down with autorepeat, a stick resting past the
71
+ threshold, and `press("a")` called twice each produce one `press` event and nothing more
72
+ until the button comes up — so a listener never has to de-duplicate. `koi.pressed` gives
73
+ the same set on demand, for a display built after the fact.
74
+
75
+ Two cases release without anyone letting go: `loadRom()` releases everything (as
76
+ `"api"`), since the new machine's joypad starts empty, and `pause()` releases whatever
77
+ the gamepad was holding (as `"gamepad"`), since polling stops and nothing else would.
78
+ Keys keep their own `keyup` while paused. `dispose()` drops every listener.
79
+
53
80
  ### Debug overlay
54
81
 
55
82
  Press <kbd>`</kbd> (backtick) to toggle a stats panel over the canvas, or call
@@ -90,6 +117,10 @@ element. Pass `overlayKey: null` to bind no key.
90
117
  - `koi.loadRom(rom)` — swaps in a new ROM, keeping the same canvas and audio setup.
91
118
  - `koi.press(button)` / `koi.release(button)` — `Button` is one of `"up"`, `"down"`,
92
119
  `"left"`, `"right"`, `"a"`, `"b"`, `"start"`, `"select"`.
120
+ - `koi.on(type, listener)` / `koi.off(type, listener)` — `"press"` and `"release"`
121
+ button edges, from any input path. `on` returns a function that unsubscribes. The
122
+ payload is `{ button, source: "keyboard" | "gamepad" | "api", pressed: Set<Button> }`.
123
+ - `koi.pressed` — everything held right now, as a `Set<Button>` snapshot.
93
124
  - `koi.toggleOverlay()` / `koi.overlayVisible` — the debug stats panel.
94
125
  - `koi.pause()` / `koi.resume()`.
95
126
  - `koi.dispose()` — stops the loop and tears down the audio graph. Call this before
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The package's event plumbing: a tiny typed emitter, plus the button-edge types it
3
+ * carries.
4
+ *
5
+ * `on`/`off` rather than `EventTarget`, because a `CustomEvent`'s payload lands in
6
+ * `detail` typed as whatever the listener's declaration says — subclassing `EventTarget`
7
+ * in TypeScript means writing `addEventListener` overloads by hand and consumers still
8
+ * end up casting. A five-line map of sets types exactly, costs nothing, and keeps the
9
+ * package free of DOM globals in a module that Node's test runner has to import.
10
+ *
11
+ * The emitter deals in *edges*, not state: `GameKoi` holds the set of what is down and
12
+ * only emits when a button actually changes, so a key held with autorepeat, a stick
13
+ * resting past the threshold, and a page calling `press` twice all produce one event.
14
+ */
15
+ import type { Button } from "./index.js";
16
+ /** Where a button edge came from. */
17
+ export type ButtonSource = "keyboard" | "gamepad" | "api";
18
+ /** A button going down or coming up. */
19
+ export interface ButtonEvent {
20
+ button: Button;
21
+ /**
22
+ * Which input path produced this edge. `"api"` covers `press`/`release` called by the
23
+ * page, and the releases `loadRom()` emits for a machine that is going away;
24
+ * `pause()`'s releases are attributed to the device that was holding them.
25
+ */
26
+ source: ButtonSource;
27
+ /**
28
+ * Everything held *after* this edge — a snapshot, safe to keep.
29
+ *
30
+ * Handy for the common case of mirroring the joypad on screen: render from this and
31
+ * there is no second copy of the state to keep in step.
32
+ */
33
+ pressed: ReadonlySet<Button>;
34
+ }
35
+ /** Event names to their payloads. See {@link GameKoi.on}. */
36
+ export interface GameKoiEvents {
37
+ press: ButtonEvent;
38
+ release: ButtonEvent;
39
+ }
40
+ export type Listener<E> = (event: E) => void;
41
+ /** Unsubscribes the listener it was returned for. Calling it twice is harmless. */
42
+ export type Unsubscribe = () => void;
43
+ /** A minimal typed event emitter. */
44
+ export declare class Emitter<Events> {
45
+ private readonly listeners;
46
+ /** Registers a listener, and returns a function that removes it again. */
47
+ on<K extends keyof Events>(type: K, listener: Listener<Events[K]>): Unsubscribe;
48
+ off<K extends keyof Events>(type: K, listener: Listener<Events[K]>): void;
49
+ /**
50
+ * Calls every listener for `type`.
51
+ *
52
+ * Each is isolated: these fire from inside a `keydown` handler and from the render
53
+ * loop, so a listener that throws would otherwise skip the rest of a gamepad poll or
54
+ * kill the loop outright. A page's display bug should not stop the emulator, so the
55
+ * error is reported and the remaining listeners still run.
56
+ *
57
+ * Iterates a copy, so a listener that unsubscribes itself (or another) mid-dispatch
58
+ * cannot disturb the walk.
59
+ */
60
+ emit<K extends keyof Events>(type: K, event: Events[K]): void;
61
+ /** Drops every listener. Called from `dispose()`. */
62
+ clear(): void;
63
+ }
package/dist/events.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The package's event plumbing: a tiny typed emitter, plus the button-edge types it
3
+ * carries.
4
+ *
5
+ * `on`/`off` rather than `EventTarget`, because a `CustomEvent`'s payload lands in
6
+ * `detail` typed as whatever the listener's declaration says — subclassing `EventTarget`
7
+ * in TypeScript means writing `addEventListener` overloads by hand and consumers still
8
+ * end up casting. A five-line map of sets types exactly, costs nothing, and keeps the
9
+ * package free of DOM globals in a module that Node's test runner has to import.
10
+ *
11
+ * The emitter deals in *edges*, not state: `GameKoi` holds the set of what is down and
12
+ * only emits when a button actually changes, so a key held with autorepeat, a stick
13
+ * resting past the threshold, and a page calling `press` twice all produce one event.
14
+ */
15
+ /** A minimal typed event emitter. */
16
+ export class Emitter {
17
+ listeners = new Map();
18
+ /** Registers a listener, and returns a function that removes it again. */
19
+ on(type, listener) {
20
+ let set = this.listeners.get(type);
21
+ if (!set) {
22
+ set = new Set();
23
+ this.listeners.set(type, set);
24
+ }
25
+ set.add(listener);
26
+ return () => this.off(type, listener);
27
+ }
28
+ off(type, listener) {
29
+ this.listeners.get(type)?.delete(listener);
30
+ }
31
+ /**
32
+ * Calls every listener for `type`.
33
+ *
34
+ * Each is isolated: these fire from inside a `keydown` handler and from the render
35
+ * loop, so a listener that throws would otherwise skip the rest of a gamepad poll or
36
+ * kill the loop outright. A page's display bug should not stop the emulator, so the
37
+ * error is reported and the remaining listeners still run.
38
+ *
39
+ * Iterates a copy, so a listener that unsubscribes itself (or another) mid-dispatch
40
+ * cannot disturb the walk.
41
+ */
42
+ emit(type, event) {
43
+ const set = this.listeners.get(type);
44
+ if (!set)
45
+ return;
46
+ for (const listener of [...set]) {
47
+ try {
48
+ listener(event);
49
+ }
50
+ catch (error) {
51
+ console.error(`game-koi: "${String(type)}" listener threw`, error);
52
+ }
53
+ }
54
+ }
55
+ /** Drops every listener. Called from `dispose()`. */
56
+ clear() {
57
+ this.listeners.clear();
58
+ }
59
+ }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * ships as a normal ES module asset next to this file, and the audio worklet is
8
8
  * inlined as a Blob URL.
9
9
  */
10
+ import { type GameKoiEvents } from "./events.js";
11
+ export type { ButtonEvent, ButtonSource, GameKoiEvents, Listener, Unsubscribe, } from "./events.js";
10
12
  export type Button = "up" | "down" | "left" | "right" | "a" | "b" | "start" | "select";
11
13
  export interface GameKoiOptions {
12
14
  /** Canvas the emulator draws into. Resized to the Game Boy's 160x144. */
@@ -53,6 +55,9 @@ export declare class GameKoi {
53
55
  private readonly targetBuffer;
54
56
  private readonly maxFramesPerWake;
55
57
  private readonly stats;
58
+ private readonly emitter;
59
+ /** What the joypad currently has down, whichever device put it there. */
60
+ private readonly held;
56
61
  private emulator;
57
62
  private buffered;
58
63
  /** Cumulative worklet counters, as last reported. See `worklet.ts`. */
@@ -77,6 +82,36 @@ export declare class GameKoi {
77
82
  loadRom(rom: Uint8Array): void;
78
83
  press(button: Button): void;
79
84
  release(button: Button): void;
85
+ /** Everything the joypad has down right now — a snapshot, safe to keep. */
86
+ get pressed(): ReadonlySet<Button>;
87
+ /**
88
+ * Listens for button edges, from any input path — the built-in keyboard and gamepad
89
+ * handling as well as `press`/`release` calls.
90
+ *
91
+ * Returns a function that removes the listener again:
92
+ *
93
+ * ```ts
94
+ * const stop = koi.on("press", ({ button, pressed }) => render(pressed));
95
+ * koi.on("release", ({ button, pressed }) => render(pressed));
96
+ * ```
97
+ *
98
+ * Only *changes* are reported: a key held down with autorepeat, a stick resting past
99
+ * the threshold, or `press("a")` twice in a row each produce one `press` event and no
100
+ * `release` until the button actually comes up.
101
+ */
102
+ on<K extends keyof GameKoiEvents>(type: K, listener: (event: GameKoiEvents[K]) => void): () => void;
103
+ /** Removes a listener registered with {@link on}. */
104
+ off<K extends keyof GameKoiEvents>(type: K, listener: (event: GameKoiEvents[K]) => void): void;
105
+ /**
106
+ * The one path to the emulated joypad, so every device's edges are counted once.
107
+ *
108
+ * A repeat is dropped here rather than forwarded: `Joypad::press` is idempotent, so
109
+ * skipping it changes nothing for the machine, and it is what makes the events edges
110
+ * instead of a restatement of whatever the browser felt like repeating.
111
+ */
112
+ private setButton;
113
+ /** Lets go of everything held, emitting the releases. */
114
+ private releaseAll;
80
115
  /**
81
116
  * Shows or hides the debug stats panel — the same thing the ` key does.
82
117
  *
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import init, { Emulator } from "../wasm/game_koi_web.js";
11
11
  import { WORKLET_SOURCE } from "./worklet.js";
12
12
  import { DEFAULT_GAMEPAD_MAP, GamepadInput } from "./gamepad.js";
13
+ import { Emitter } from "./events.js";
13
14
  import { FrameStats } from "./stats.js";
14
15
  import { StatsOverlay } from "./overlay.js";
15
16
  const DEFAULT_KEYMAP = {
@@ -42,6 +43,9 @@ export class GameKoi {
42
43
  targetBuffer;
43
44
  maxFramesPerWake;
44
45
  stats;
46
+ emitter = new Emitter();
47
+ /** What the joypad currently has down, whichever device put it there. */
48
+ held = new Set();
45
49
  emulator;
46
50
  buffered = 0;
47
51
  /** Cumulative worklet counters, as last reported. See `worklet.ts`. */
@@ -112,18 +116,76 @@ export class GameKoi {
112
116
  }
113
117
  /** Swaps in a new ROM, keeping the same canvas and audio graph. */
114
118
  loadRom(rom) {
115
- // The new machine's joypad starts with nothing held, so the snapshot of what is
116
- // held has to start over too or the first poll will emit no press for a direction
117
- // that was already down.
119
+ // The new machine's joypad starts with nothing held, so what this side believes is
120
+ // held has to start over too — both the gamepad's snapshot (or the first poll would
121
+ // emit no press for a direction that was already down) and the set the events are
122
+ // edges against (or a button "already held" would never be pressed on the new
123
+ // machine, and a display would show it stuck down).
118
124
  this.gamepad?.releaseAll();
125
+ this.releaseAll();
119
126
  this.emulator.free();
120
127
  this.emulator = new Emulator(rom, this.audioContext.sampleRate);
121
128
  }
122
129
  press(button) {
123
- this.emulator.press(button);
130
+ this.setButton(button, true, "api");
124
131
  }
125
132
  release(button) {
126
- this.emulator.release(button);
133
+ this.setButton(button, false, "api");
134
+ }
135
+ /** Everything the joypad has down right now — a snapshot, safe to keep. */
136
+ get pressed() {
137
+ return new Set(this.held);
138
+ }
139
+ /**
140
+ * Listens for button edges, from any input path — the built-in keyboard and gamepad
141
+ * handling as well as `press`/`release` calls.
142
+ *
143
+ * Returns a function that removes the listener again:
144
+ *
145
+ * ```ts
146
+ * const stop = koi.on("press", ({ button, pressed }) => render(pressed));
147
+ * koi.on("release", ({ button, pressed }) => render(pressed));
148
+ * ```
149
+ *
150
+ * Only *changes* are reported: a key held down with autorepeat, a stick resting past
151
+ * the threshold, or `press("a")` twice in a row each produce one `press` event and no
152
+ * `release` until the button actually comes up.
153
+ */
154
+ on(type, listener) {
155
+ return this.emitter.on(type, listener);
156
+ }
157
+ /** Removes a listener registered with {@link on}. */
158
+ off(type, listener) {
159
+ this.emitter.off(type, listener);
160
+ }
161
+ /**
162
+ * The one path to the emulated joypad, so every device's edges are counted once.
163
+ *
164
+ * A repeat is dropped here rather than forwarded: `Joypad::press` is idempotent, so
165
+ * skipping it changes nothing for the machine, and it is what makes the events edges
166
+ * instead of a restatement of whatever the browser felt like repeating.
167
+ */
168
+ setButton(button, pressed, source) {
169
+ if (this.held.has(button) === pressed)
170
+ return;
171
+ if (pressed) {
172
+ this.held.add(button);
173
+ this.emulator.press(button);
174
+ }
175
+ else {
176
+ this.held.delete(button);
177
+ this.emulator.release(button);
178
+ }
179
+ this.emitter.emit(pressed ? "press" : "release", {
180
+ button,
181
+ source,
182
+ pressed: new Set(this.held),
183
+ });
184
+ }
185
+ /** Lets go of everything held, emitting the releases. */
186
+ releaseAll() {
187
+ for (const button of [...this.held])
188
+ this.setButton(button, false, "api");
127
189
  }
128
190
  /**
129
191
  * Shows or hides the debug stats panel — the same thing the ` key does.
@@ -161,6 +223,7 @@ export class GameKoi {
161
223
  this.detachKeyboard();
162
224
  if (this.overlayListener)
163
225
  removeEventListener("keydown", this.overlayListener);
226
+ this.emitter.clear();
164
227
  this.overlay?.dispose();
165
228
  this.emulator.free();
166
229
  this.worklet.disconnect();
@@ -255,24 +318,23 @@ export class GameKoi {
255
318
  }
256
319
  applyGamepadActions(actions) {
257
320
  for (const action of actions ?? []) {
258
- if (action.type === "press")
259
- this.press(action.button);
260
- else
261
- this.release(action.button);
321
+ this.setButton(action.button, action.type === "press", "gamepad");
262
322
  }
263
323
  }
264
324
  attachKeyboard() {
265
325
  this.keydownListener = (event) => {
266
326
  const button = this.keymap?.[event.code];
267
327
  if (button) {
268
- this.press(button);
328
+ // Autorepeat still arrives here: `preventDefault` has to run on every repeat,
329
+ // and `setButton` is what collapses them into the one press edge.
330
+ this.setButton(button, true, "keyboard");
269
331
  event.preventDefault();
270
332
  }
271
333
  };
272
334
  this.keyupListener = (event) => {
273
335
  const button = this.keymap?.[event.code];
274
336
  if (button) {
275
- this.release(button);
337
+ this.setButton(button, false, "keyboard");
276
338
  event.preventDefault();
277
339
  }
278
340
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "game-koi",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A Game Boy (DMG) emulator, compiled to WebAssembly, with a drop-in canvas + audio wrapper",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
Binary file