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 +31 -0
- package/dist/events.d.ts +63 -0
- package/dist/events.js +59 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +73 -11
- package/package.json +1 -1
- package/wasm/game_koi_web_bg.wasm +0 -0
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
|
package/dist/events.d.ts
ADDED
|
@@ -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
|
|
116
|
-
// held has to start over too
|
|
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.
|
|
130
|
+
this.setButton(button, true, "api");
|
|
124
131
|
}
|
|
125
132
|
release(button) {
|
|
126
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
337
|
+
this.setButton(button, false, "keyboard");
|
|
276
338
|
event.preventDefault();
|
|
277
339
|
}
|
|
278
340
|
};
|
package/package.json
CHANGED
|
Binary file
|