game-koi 0.1.2 → 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 +82 -4
- package/dist/events.d.ts +63 -0
- package/dist/events.js +59 -0
- package/dist/gamepad.d.ts +85 -0
- package/dist/gamepad.js +158 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +184 -6
- package/dist/overlay.d.ts +87 -0
- package/dist/overlay.js +245 -0
- package/dist/stats.d.ts +116 -0
- package/dist/stats.js +120 -0
- package/dist/worklet.d.ts +1 -1
- package/dist/worklet.js +19 -2
- package/package.json +2 -1
- package/wasm/game_koi_web_bg.wasm +0 -0
package/README.md
CHANGED
|
@@ -26,10 +26,78 @@ button.addEventListener("click", async () => {
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
That's the whole integration: no separate files to serve for the audio worklet, no
|
|
29
|
-
manual wasm memory or canvas bookkeeping, and
|
|
30
|
-
keys, Z/X, Enter, right Shift)
|
|
31
|
-
|
|
32
|
-
touch controls
|
|
29
|
+
manual wasm memory or canvas bookkeeping, and both input devices are wired up
|
|
30
|
+
automatically — the keyboard layout (arrow keys, Z/X, Enter, right Shift) and any
|
|
31
|
+
connected gamepad. Pass `keymap: null` or `gamepadMap: null` to `create()` to opt out
|
|
32
|
+
of either and drive `press`/`release` yourself — from touch controls or your own
|
|
33
|
+
bindings.
|
|
34
|
+
|
|
35
|
+
### Gamepads
|
|
36
|
+
|
|
37
|
+
A standard-layout pad maps d-pad to d-pad, the East and South face buttons to A and B
|
|
38
|
+
(the DMG's diagonal), Start to Start and Back/Select to Select. The left analog stick
|
|
39
|
+
doubles as the d-pad, thresholded at half deflection. Every connected pad drives the
|
|
40
|
+
one player, since the Game Boy has only one.
|
|
41
|
+
|
|
42
|
+
Two things follow from how the browser exposes pads, and neither needs anything from
|
|
43
|
+
the page:
|
|
44
|
+
|
|
45
|
+
- There is no event for a button being pressed — only connect/disconnect — so pads are
|
|
46
|
+
polled once per animation frame. That is also why a pad shows up on its own in
|
|
47
|
+
Chrome, which hides pads until one has been interacted with.
|
|
48
|
+
- Pads the browser cannot identify (`mapping !== "standard"`) are ignored, because the
|
|
49
|
+
button indices of an unrecognised layout are whatever the driver enumerated and
|
|
50
|
+
binding them would be confidently wrong rather than merely absent. Use
|
|
51
|
+
`gamepadMap: null` and your own polling for such a pad.
|
|
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
|
+
|
|
80
|
+
### Debug overlay
|
|
81
|
+
|
|
82
|
+
Press <kbd>`</kbd> (backtick) to toggle a stats panel over the canvas, or call
|
|
83
|
+
`koi.toggleOverlay()`. It is the browser counterpart of the desktop build's panel, on
|
|
84
|
+
the same key, and it counts browser-shaped things:
|
|
85
|
+
|
|
86
|
+
- **FPS / Speed** — emulated frames per second, and that as a percentage of a real
|
|
87
|
+
DMG's 59.73 Hz. Frames, not animation-frame wakes: on a 120 Hz display the loop wakes
|
|
88
|
+
twice per frame.
|
|
89
|
+
- **emulate / draw / Work** — milliseconds per emulated frame, and their total as a
|
|
90
|
+
share of the 16.74 ms a frame is worth.
|
|
91
|
+
- **Frames/wake, Refresh, Capped** — how the two clocks relate, and how often
|
|
92
|
+
`maxFramesPerWake` clamped a catch-up.
|
|
93
|
+
- **Buffer / Underruns / Dropped / Output** — how much audio is queued ahead, and the
|
|
94
|
+
two ways that goes wrong. Underruns are silence that was actually heard, and are the
|
|
95
|
+
closest thing here to the desktop's "late frames"; a `suspended` output is the usual
|
|
96
|
+
reason for no sound at all.
|
|
97
|
+
|
|
98
|
+
The panel is read-only and `pointer-events: none`, so it never intercepts a click, and
|
|
99
|
+
it is built the first time it is opened — a page that never opens it gets no extra
|
|
100
|
+
element. Pass `overlayKey: null` to bind no key.
|
|
33
101
|
|
|
34
102
|
## API
|
|
35
103
|
|
|
@@ -38,12 +106,22 @@ touch controls, or your own key bindings.
|
|
|
38
106
|
- `rom: Uint8Array` — the `.gb` file's bytes.
|
|
39
107
|
- `keymap?: Record<string, Button> | null` — maps `KeyboardEvent.code` to a
|
|
40
108
|
button; `null` disables built-in keyboard handling.
|
|
109
|
+
- `gamepadMap?: Record<number, Button> | null` — maps a standard-layout gamepad's
|
|
110
|
+
button indices to a button; `null` disables gamepad polling. The left stick acts
|
|
111
|
+
as a d-pad regardless of this map.
|
|
112
|
+
- `overlayKey?: string | null` — `KeyboardEvent.code` toggling the stats panel.
|
|
113
|
+
Default `"Backquote"`; `null` binds no key.
|
|
41
114
|
- `targetBuffer?: number` — audio samples to keep queued ahead. Default `1600`.
|
|
42
115
|
- `maxFramesPerWake?: number` — cap on frames emulated per wake-up, so a
|
|
43
116
|
backgrounded tab can't return to a freeze. Default `4`.
|
|
44
117
|
- `koi.loadRom(rom)` — swaps in a new ROM, keeping the same canvas and audio setup.
|
|
45
118
|
- `koi.press(button)` / `koi.release(button)` — `Button` is one of `"up"`, `"down"`,
|
|
46
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.
|
|
124
|
+
- `koi.toggleOverlay()` / `koi.overlayVisible` — the debug stats panel.
|
|
47
125
|
- `koi.pause()` / `koi.resume()`.
|
|
48
126
|
- `koi.dispose()` — stops the loop and tears down the audio graph. Call this before
|
|
49
127
|
dropping a `GameKoi` instance, or the AudioContext and its worklet leak.
|
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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad input, via the browser's Gamepad API.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the keyboard, a gamepad is not a source of events here: the Gamepad API has
|
|
5
|
+
* `gamepadconnected`/`gamepaddisconnected` and nothing else — a button press fires no
|
|
6
|
+
* event at all. `navigator.getGamepads()` hands back a *snapshot* of every pad's
|
|
7
|
+
* current state, so this is polled once per wake of the render loop and diffed against
|
|
8
|
+
* the previous snapshot to recover the press/release edges the joypad wants. One frame
|
|
9
|
+
* (~16.7 ms) of latency is the granularity the emulator already runs at, so polling
|
|
10
|
+
* more often would buy nothing.
|
|
11
|
+
*
|
|
12
|
+
* Polling is also what makes a pad appear at all: Chrome exposes no pads until one has
|
|
13
|
+
* been interacted with, and reveals them through a later `getGamepads()` call rather
|
|
14
|
+
* than retroactively. Nothing extra is needed to pick that up.
|
|
15
|
+
*
|
|
16
|
+
* # Analog sticks are not a d-pad
|
|
17
|
+
*
|
|
18
|
+
* The DMG's d-pad is four switches: a direction is held or it isn't. A stick reports a
|
|
19
|
+
* continuous position, so it is thresholded into at most one direction per axis. The
|
|
20
|
+
* diff against the previous snapshot is what keeps a resting-at-0.7 stick from
|
|
21
|
+
* re-pressing Right on every frame.
|
|
22
|
+
*/
|
|
23
|
+
import type { Button } from "./index.js";
|
|
24
|
+
/**
|
|
25
|
+
* The part of the browser's `Gamepad` this module reads.
|
|
26
|
+
*
|
|
27
|
+
* Narrowed to the two fields that matter (a real `Gamepad` is structurally compatible)
|
|
28
|
+
* so the mapping can be unit-tested with plain objects and no browser attached.
|
|
29
|
+
*/
|
|
30
|
+
export interface PadState {
|
|
31
|
+
/** `"standard"` iff the browser recognised the layout; see `heldButtons`. */
|
|
32
|
+
mapping: string;
|
|
33
|
+
buttons: readonly {
|
|
34
|
+
readonly pressed: boolean;
|
|
35
|
+
}[];
|
|
36
|
+
axes: readonly number[];
|
|
37
|
+
}
|
|
38
|
+
/** A press or release edge, to be applied to the emulator's joypad. */
|
|
39
|
+
export interface Action {
|
|
40
|
+
type: "press" | "release";
|
|
41
|
+
button: Button;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Standard-layout button indices mapped to Game Boy buttons.
|
|
45
|
+
*
|
|
46
|
+
* A and B sit on a diagonal on the DMG — B lower-left, A upper-right — so the East and
|
|
47
|
+
* South face buttons reproduce that under the thumb. The Gamepad API reports face
|
|
48
|
+
* buttons by *position*, not by printed label: index 0 is always the bottom button and
|
|
49
|
+
* index 1 the right-hand one, whatever a given pad prints on them.
|
|
50
|
+
*/
|
|
51
|
+
export declare const DEFAULT_GAMEPAD_MAP: Readonly<Record<number, Button>>;
|
|
52
|
+
/**
|
|
53
|
+
* Everything held across every connected pad, as a set of Game Boy buttons.
|
|
54
|
+
*
|
|
55
|
+
* Every pad is accepted rather than picking a "player 1": the Game Boy has one player,
|
|
56
|
+
* so asking which controller owns it would be ceremony for nothing.
|
|
57
|
+
*
|
|
58
|
+
* Pads whose `mapping` is not `"standard"` are skipped entirely. The indices in a
|
|
59
|
+
* non-standard layout are whatever the driver happened to enumerate, so guessing at
|
|
60
|
+
* them produces confidently wrong bindings rather than none; a page with such a pad can
|
|
61
|
+
* pass `gamepadMap: null` and drive `press`/`release` itself.
|
|
62
|
+
*/
|
|
63
|
+
export declare function heldButtons(pads: Iterable<PadState | null>, map: Readonly<Record<number, Button>>): Set<Button>;
|
|
64
|
+
/**
|
|
65
|
+
* The edges between two snapshots.
|
|
66
|
+
*
|
|
67
|
+
* Releases come first so that flicking a stick from Left straight through centre to
|
|
68
|
+
* Right cannot leave Left stuck down for the instant the presses are applied.
|
|
69
|
+
*/
|
|
70
|
+
export declare function diff(previous: ReadonlySet<Button>, next: ReadonlySet<Button>): Action[];
|
|
71
|
+
/** Remembers the last snapshot so each poll can report only what changed. */
|
|
72
|
+
export declare class GamepadInput {
|
|
73
|
+
private readonly map;
|
|
74
|
+
private held;
|
|
75
|
+
constructor(map?: Readonly<Record<number, Button>>);
|
|
76
|
+
/** Diffs the pads against the previous poll and returns the edges to apply. */
|
|
77
|
+
poll(pads: Iterable<PadState | null>): Action[];
|
|
78
|
+
/**
|
|
79
|
+
* Releases everything currently held, forgetting the snapshot.
|
|
80
|
+
*
|
|
81
|
+
* Used when the loop stops: a button held at the moment of a pause would otherwise
|
|
82
|
+
* still be held from the emulator's point of view, with no poll coming to release it.
|
|
83
|
+
*/
|
|
84
|
+
releaseAll(): Action[];
|
|
85
|
+
}
|
package/dist/gamepad.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad input, via the browser's Gamepad API.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the keyboard, a gamepad is not a source of events here: the Gamepad API has
|
|
5
|
+
* `gamepadconnected`/`gamepaddisconnected` and nothing else — a button press fires no
|
|
6
|
+
* event at all. `navigator.getGamepads()` hands back a *snapshot* of every pad's
|
|
7
|
+
* current state, so this is polled once per wake of the render loop and diffed against
|
|
8
|
+
* the previous snapshot to recover the press/release edges the joypad wants. One frame
|
|
9
|
+
* (~16.7 ms) of latency is the granularity the emulator already runs at, so polling
|
|
10
|
+
* more often would buy nothing.
|
|
11
|
+
*
|
|
12
|
+
* Polling is also what makes a pad appear at all: Chrome exposes no pads until one has
|
|
13
|
+
* been interacted with, and reveals them through a later `getGamepads()` call rather
|
|
14
|
+
* than retroactively. Nothing extra is needed to pick that up.
|
|
15
|
+
*
|
|
16
|
+
* # Analog sticks are not a d-pad
|
|
17
|
+
*
|
|
18
|
+
* The DMG's d-pad is four switches: a direction is held or it isn't. A stick reports a
|
|
19
|
+
* continuous position, so it is thresholded into at most one direction per axis. The
|
|
20
|
+
* diff against the previous snapshot is what keeps a resting-at-0.7 stick from
|
|
21
|
+
* re-pressing Right on every frame.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Standard-layout button indices mapped to Game Boy buttons.
|
|
25
|
+
*
|
|
26
|
+
* A and B sit on a diagonal on the DMG — B lower-left, A upper-right — so the East and
|
|
27
|
+
* South face buttons reproduce that under the thumb. The Gamepad API reports face
|
|
28
|
+
* buttons by *position*, not by printed label: index 0 is always the bottom button and
|
|
29
|
+
* index 1 the right-hand one, whatever a given pad prints on them.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_GAMEPAD_MAP = {
|
|
32
|
+
0: "b", // South
|
|
33
|
+
1: "a", // East
|
|
34
|
+
8: "select", // Back/Select
|
|
35
|
+
9: "start",
|
|
36
|
+
12: "up",
|
|
37
|
+
13: "down",
|
|
38
|
+
14: "left",
|
|
39
|
+
15: "right",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* How far a stick must be pushed before it counts as a direction press.
|
|
43
|
+
*
|
|
44
|
+
* Half deflection, the same figure the desktop build uses: high enough that a drifting
|
|
45
|
+
* or off-centre stick does not hold a direction on its own, low enough that a diagonal
|
|
46
|
+
* registers on both axes.
|
|
47
|
+
*/
|
|
48
|
+
const STICK_THRESHOLD = 0.5;
|
|
49
|
+
/** Standard-layout axis indices. The right stick (2, 3) is deliberately ignored. */
|
|
50
|
+
const LEFT_STICK_X = 0;
|
|
51
|
+
const LEFT_STICK_Y = 1;
|
|
52
|
+
/**
|
|
53
|
+
* Everything held across every connected pad, as a set of Game Boy buttons.
|
|
54
|
+
*
|
|
55
|
+
* Every pad is accepted rather than picking a "player 1": the Game Boy has one player,
|
|
56
|
+
* so asking which controller owns it would be ceremony for nothing.
|
|
57
|
+
*
|
|
58
|
+
* Pads whose `mapping` is not `"standard"` are skipped entirely. The indices in a
|
|
59
|
+
* non-standard layout are whatever the driver happened to enumerate, so guessing at
|
|
60
|
+
* them produces confidently wrong bindings rather than none; a page with such a pad can
|
|
61
|
+
* pass `gamepadMap: null` and drive `press`/`release` itself.
|
|
62
|
+
*/
|
|
63
|
+
export function heldButtons(pads, map) {
|
|
64
|
+
const held = new Set();
|
|
65
|
+
for (const pad of pads) {
|
|
66
|
+
if (!pad || pad.mapping !== "standard")
|
|
67
|
+
continue;
|
|
68
|
+
pad.buttons.forEach((button, index) => {
|
|
69
|
+
// `pressed` rather than a threshold on `value`: the browser already applies the
|
|
70
|
+
// right one per button, and the Game Boy has no analog inputs to preserve.
|
|
71
|
+
if (!button.pressed)
|
|
72
|
+
return;
|
|
73
|
+
const mapped = map[index];
|
|
74
|
+
if (mapped)
|
|
75
|
+
held.add(mapped);
|
|
76
|
+
});
|
|
77
|
+
// The stick is a direction, not an index, so it is not part of `map`. Note the Y
|
|
78
|
+
// sign: the Gamepad API reports +1 as *down*, the opposite of the convention the
|
|
79
|
+
// desktop build's gilrs uses.
|
|
80
|
+
addAxis(held, pad.axes[LEFT_STICK_X], "left", "right");
|
|
81
|
+
addAxis(held, pad.axes[LEFT_STICK_Y], "up", "down");
|
|
82
|
+
}
|
|
83
|
+
cancelOpposites(held);
|
|
84
|
+
return held;
|
|
85
|
+
}
|
|
86
|
+
/** Adds whichever direction an axis position is holding, if any. */
|
|
87
|
+
function addAxis(held, value, negative, positive) {
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
return;
|
|
90
|
+
if (value <= -STICK_THRESHOLD)
|
|
91
|
+
held.add(negative);
|
|
92
|
+
else if (value >= STICK_THRESHOLD)
|
|
93
|
+
held.add(positive);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Drops both halves of an opposing pair held at once.
|
|
97
|
+
*
|
|
98
|
+
* A physical d-pad cannot produce Left+Right — it is one rocker — and some games handle
|
|
99
|
+
* the impossible state badly. Two sources can still ask for it here: a stick pushed one
|
|
100
|
+
* way while the d-pad is held the other, or two pads disagreeing. Neutral is the
|
|
101
|
+
* closest reachable state.
|
|
102
|
+
*/
|
|
103
|
+
function cancelOpposites(held) {
|
|
104
|
+
const pairs = [
|
|
105
|
+
["left", "right"],
|
|
106
|
+
["up", "down"],
|
|
107
|
+
];
|
|
108
|
+
for (const [a, b] of pairs) {
|
|
109
|
+
if (held.has(a) && held.has(b)) {
|
|
110
|
+
held.delete(a);
|
|
111
|
+
held.delete(b);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The edges between two snapshots.
|
|
117
|
+
*
|
|
118
|
+
* Releases come first so that flicking a stick from Left straight through centre to
|
|
119
|
+
* Right cannot leave Left stuck down for the instant the presses are applied.
|
|
120
|
+
*/
|
|
121
|
+
export function diff(previous, next) {
|
|
122
|
+
const actions = [];
|
|
123
|
+
for (const button of previous) {
|
|
124
|
+
if (!next.has(button))
|
|
125
|
+
actions.push({ type: "release", button });
|
|
126
|
+
}
|
|
127
|
+
for (const button of next) {
|
|
128
|
+
if (!previous.has(button))
|
|
129
|
+
actions.push({ type: "press", button });
|
|
130
|
+
}
|
|
131
|
+
return actions;
|
|
132
|
+
}
|
|
133
|
+
/** Remembers the last snapshot so each poll can report only what changed. */
|
|
134
|
+
export class GamepadInput {
|
|
135
|
+
map;
|
|
136
|
+
held = new Set();
|
|
137
|
+
constructor(map = DEFAULT_GAMEPAD_MAP) {
|
|
138
|
+
this.map = map;
|
|
139
|
+
}
|
|
140
|
+
/** Diffs the pads against the previous poll and returns the edges to apply. */
|
|
141
|
+
poll(pads) {
|
|
142
|
+
const next = heldButtons(pads, this.map);
|
|
143
|
+
const actions = diff(this.held, next);
|
|
144
|
+
this.held = next;
|
|
145
|
+
return actions;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Releases everything currently held, forgetting the snapshot.
|
|
149
|
+
*
|
|
150
|
+
* Used when the loop stops: a button held at the moment of a pause would otherwise
|
|
151
|
+
* still be held from the emulator's point of view, with no poll coming to release it.
|
|
152
|
+
*/
|
|
153
|
+
releaseAll() {
|
|
154
|
+
const actions = diff(this.held, new Set());
|
|
155
|
+
this.held = new Set();
|
|
156
|
+
return actions;
|
|
157
|
+
}
|
|
158
|
+
}
|
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. */
|
|
@@ -19,6 +21,19 @@ export interface GameKoiOptions {
|
|
|
19
21
|
* build's layout: arrow keys, Z/X, Enter, right Shift.
|
|
20
22
|
*/
|
|
21
23
|
keymap?: Record<string, Button> | null;
|
|
24
|
+
/**
|
|
25
|
+
* Maps a standard-layout gamepad's button indices to a button. Pass `null` to
|
|
26
|
+
* disable gamepad polling entirely. Defaults to the desktop build's layout: d-pad,
|
|
27
|
+
* East/South face buttons for A/B, Start and Back/Select. The left analog stick acts
|
|
28
|
+
* as a d-pad regardless of this map, since it is a direction rather than an index.
|
|
29
|
+
*/
|
|
30
|
+
gamepadMap?: Record<number, Button> | null;
|
|
31
|
+
/**
|
|
32
|
+
* `KeyboardEvent.code` that toggles the debug stats panel. Default `"Backquote"`
|
|
33
|
+
* (the ` / ~ key). Pass `null` to bind no key and drive {@link GameKoi.toggleOverlay}
|
|
34
|
+
* yourself. Like `keymap`, this is a physical key position, not a character.
|
|
35
|
+
*/
|
|
36
|
+
overlayKey?: string | null;
|
|
22
37
|
/** Samples to keep queued ahead of playback. Default 1600 (~2 frames at 48kHz). */
|
|
23
38
|
targetBuffer?: number;
|
|
24
39
|
/**
|
|
@@ -36,14 +51,25 @@ export declare class GameKoi {
|
|
|
36
51
|
private readonly wasmMemory;
|
|
37
52
|
private readonly imageData;
|
|
38
53
|
private readonly keymap;
|
|
54
|
+
private readonly gamepad;
|
|
39
55
|
private readonly targetBuffer;
|
|
40
56
|
private readonly maxFramesPerWake;
|
|
57
|
+
private readonly stats;
|
|
58
|
+
private readonly emitter;
|
|
59
|
+
/** What the joypad currently has down, whichever device put it there. */
|
|
60
|
+
private readonly held;
|
|
41
61
|
private emulator;
|
|
42
62
|
private buffered;
|
|
63
|
+
/** Cumulative worklet counters, as last reported. See `worklet.ts`. */
|
|
64
|
+
private underruns;
|
|
65
|
+
private dropped;
|
|
66
|
+
/** Built on first toggle, so a page that never opens it gets no element. */
|
|
67
|
+
private overlay;
|
|
43
68
|
private running;
|
|
44
69
|
private rafHandle;
|
|
45
70
|
private keydownListener?;
|
|
46
71
|
private keyupListener?;
|
|
72
|
+
private overlayListener?;
|
|
47
73
|
private constructor();
|
|
48
74
|
/**
|
|
49
75
|
* Builds and starts a running emulator.
|
|
@@ -56,12 +82,75 @@ export declare class GameKoi {
|
|
|
56
82
|
loadRom(rom: Uint8Array): void;
|
|
57
83
|
press(button: Button): void;
|
|
58
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;
|
|
115
|
+
/**
|
|
116
|
+
* Shows or hides the debug stats panel — the same thing the ` key does.
|
|
117
|
+
*
|
|
118
|
+
* The panel is built the first time this is called, so a page that never opens it
|
|
119
|
+
* never gets an extra element in its DOM.
|
|
120
|
+
*/
|
|
121
|
+
toggleOverlay(): void;
|
|
122
|
+
/** Whether the stats panel is currently showing. */
|
|
123
|
+
get overlayVisible(): boolean;
|
|
59
124
|
pause(): void;
|
|
60
125
|
resume(): void;
|
|
61
126
|
/** Stops the loop, releases wasm memory, and tears down the audio graph. */
|
|
62
127
|
dispose(): void;
|
|
63
128
|
private loop;
|
|
129
|
+
/**
|
|
130
|
+
* Feeds the counters and refreshes the panel.
|
|
131
|
+
*
|
|
132
|
+
* Always run, not just while the panel is open: the averages are computed over half a
|
|
133
|
+
* second, so a panel that only started counting when it opened would show nothing for
|
|
134
|
+
* its first window. The cost is four `performance.now()` reads per wake.
|
|
135
|
+
*/
|
|
136
|
+
private recordWake;
|
|
64
137
|
private drawFrame;
|
|
138
|
+
/**
|
|
139
|
+
* Reads every connected pad and applies whatever changed since the last wake.
|
|
140
|
+
*
|
|
141
|
+
* Done here rather than from a listener because the Gamepad API fires no event for a
|
|
142
|
+
* button: `getGamepads()` is a snapshot and polling it is the only way to see one.
|
|
143
|
+
*/
|
|
144
|
+
private pollGamepads;
|
|
145
|
+
private applyGamepadActions;
|
|
65
146
|
private attachKeyboard;
|
|
66
147
|
private detachKeyboard;
|
|
148
|
+
/**
|
|
149
|
+
* Binds the panel's toggle key.
|
|
150
|
+
*
|
|
151
|
+
* Its own listener rather than a case inside the keymap one, because the two are
|
|
152
|
+
* independent: a page that drives the joypad itself (`keymap: null`) can still want
|
|
153
|
+
* the panel, and the panel key is not a Game Boy button.
|
|
154
|
+
*/
|
|
155
|
+
private attachOverlayKey;
|
|
67
156
|
}
|