@driftengine/native-host 4.0.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/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +58 -0
- package/dist/activation.d.ts +26 -0
- package/dist/activation.js +38 -0
- package/dist/alphaCopy.d.ts +36 -0
- package/dist/alphaCopy.js +178 -0
- package/dist/audio.d.ts +55 -0
- package/dist/audio.js +193 -0
- package/dist/audioParams.d.ts +25 -0
- package/dist/audioParams.js +128 -0
- package/dist/bridge.d.ts +70 -0
- package/dist/bridge.js +170 -0
- package/dist/canvas.d.ts +97 -0
- package/dist/canvas.js +197 -0
- package/dist/canvasStyle.d.ts +23 -0
- package/dist/canvasStyle.js +79 -0
- package/dist/device.d.ts +34 -0
- package/dist/device.js +144 -0
- package/dist/dialogs.d.ts +23 -0
- package/dist/dialogs.js +85 -0
- package/dist/domEvents.d.ts +37 -0
- package/dist/domEvents.js +145 -0
- package/dist/domTree.d.ts +34 -0
- package/dist/domTree.js +194 -0
- package/dist/dualRumble.d.ts +19 -0
- package/dist/dualRumble.js +47 -0
- package/dist/eventClasses.d.ts +12 -0
- package/dist/eventClasses.js +35 -0
- package/dist/files.d.ts +16 -0
- package/dist/files.js +59 -0
- package/dist/fullscreen.d.ts +33 -0
- package/dist/fullscreen.js +65 -0
- package/dist/game.d.ts +25 -0
- package/dist/game.js +38 -0
- package/dist/gamepads.d.ts +65 -0
- package/dist/gamepads.js +178 -0
- package/dist/globals.d.ts +24 -0
- package/dist/globals.js +37 -0
- package/dist/gpu.d.ts +26 -0
- package/dist/gpu.js +47 -0
- package/dist/images.d.ts +50 -0
- package/dist/images.js +111 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +21 -0
- package/dist/keyCodes.d.ts +30 -0
- package/dist/keyCodes.js +205 -0
- package/dist/keyEvents.d.ts +54 -0
- package/dist/keyEvents.js +117 -0
- package/dist/mediaQueries.d.ts +62 -0
- package/dist/mediaQueries.js +152 -0
- package/dist/mouseEvents.d.ts +96 -0
- package/dist/mouseEvents.js +287 -0
- package/dist/page.d.ts +118 -0
- package/dist/page.js +302 -0
- package/dist/pointerCapture.d.ts +32 -0
- package/dist/pointerCapture.js +69 -0
- package/dist/present.d.ts +27 -0
- package/dist/present.js +83 -0
- package/dist/rawPad.d.ts +44 -0
- package/dist/rawPad.js +65 -0
- package/dist/readback.d.ts +18 -0
- package/dist/readback.js +46 -0
- package/dist/runtime.d.ts +69 -0
- package/dist/runtime.js +103 -0
- package/dist/standardPad.d.ts +49 -0
- package/dist/standardPad.js +80 -0
- package/dist/textHost.d.ts +37 -0
- package/dist/textHost.js +43 -0
- package/dist/threads.d.ts +27 -0
- package/dist/threads.js +59 -0
- package/dist/tileFiles.d.ts +16 -0
- package/dist/tileFiles.js +36 -0
- package/dist/touchEvents.d.ts +67 -0
- package/dist/touchEvents.js +210 -0
- package/dist/uiEvents.d.ts +157 -0
- package/dist/uiEvents.js +206 -0
- package/dist/uiTouch.d.ts +59 -0
- package/dist/uiTouch.js +75 -0
- package/dist/window.d.ts +71 -0
- package/dist/window.js +164 -0
- package/dist/windowMode.d.ts +19 -0
- package/dist/windowMode.js +15 -0
- package/dist/windowTitle.d.ts +18 -0
- package/dist/windowTitle.js +33 -0
- package/dist/workerScope.mjs +48 -0
- package/licenses/GPL-3.0.txt +674 -0
- package/package.json +83 -0
- package/src/activation.ts +42 -0
- package/src/alphaCopy.ts +201 -0
- package/src/audio.ts +246 -0
- package/src/audioParams.ts +150 -0
- package/src/bridge.ts +230 -0
- package/src/canvas.ts +227 -0
- package/src/canvasStyle.ts +89 -0
- package/src/device.ts +167 -0
- package/src/dialogs.ts +98 -0
- package/src/domEvents.ts +170 -0
- package/src/domTree.ts +215 -0
- package/src/drs.d.ts +9 -0
- package/src/dualRumble.ts +58 -0
- package/src/eventClasses.ts +46 -0
- package/src/files.ts +61 -0
- package/src/fixtures/grey.jpg +0 -0
- package/src/fixtures/rgb.png +0 -0
- package/src/fixtures/tone.opus +0 -0
- package/src/fullscreen.ts +71 -0
- package/src/game.ts +53 -0
- package/src/gamepads.ts +195 -0
- package/src/globals.ts +39 -0
- package/src/gpu.ts +59 -0
- package/src/images.ts +148 -0
- package/src/index.ts +24 -0
- package/src/keyCodes.ts +203 -0
- package/src/keyEvents.ts +135 -0
- package/src/mediaQueries.ts +173 -0
- package/src/mouseEvents.ts +328 -0
- package/src/page.ts +337 -0
- package/src/pointerCapture.ts +73 -0
- package/src/present.ts +92 -0
- package/src/rawPad.ts +85 -0
- package/src/readback.ts +61 -0
- package/src/runtime.ts +162 -0
- package/src/standardPad.ts +126 -0
- package/src/textHost.ts +66 -0
- package/src/threads.ts +66 -0
- package/src/tileFiles.ts +39 -0
- package/src/touchEvents.ts +238 -0
- package/src/uiEvents.ts +268 -0
- package/src/uiTouch.ts +97 -0
- package/src/window.ts +192 -0
- package/src/windowMode.ts +24 -0
- package/src/windowTitle.ts +36 -0
- package/src/workerScope.mjs +48 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pad's `vibrationActuator`, the standard's `dual-rumble` effect, driven through SDL's rumble.
|
|
3
|
+
*
|
|
4
|
+
* The strong magnitude drives the low-frequency motor and the weak one the high-frequency motor,
|
|
5
|
+
* the pair every rumbling pad has. A later effect preempts the one playing, whose promise then
|
|
6
|
+
* settles `preempted`, as the standard describes; one that runs its course settles `complete`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What this drives of an SDL pad, a controller's or a joystick's. */
|
|
10
|
+
export interface Rumbler {
|
|
11
|
+
rumble(low: number, high: number, durationMs: number): void;
|
|
12
|
+
stopRumble(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DualRumble {
|
|
16
|
+
readonly type: 'dual-rumble';
|
|
17
|
+
readonly effects: readonly string[];
|
|
18
|
+
playEffect(type: string, params: Readonly<Record<string, number>>): Promise<string>;
|
|
19
|
+
reset(): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function dualRumble(motors: Rumbler): DualRumble {
|
|
23
|
+
let settle: ((result: string) => void) | null = null;
|
|
24
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
const finish = (result: string): void => {
|
|
26
|
+
if (timer !== null) clearTimeout(timer);
|
|
27
|
+
timer = null;
|
|
28
|
+
const settling = settle;
|
|
29
|
+
settle = null;
|
|
30
|
+
settling?.(result);
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
type: 'dual-rumble',
|
|
34
|
+
effects: ['dual-rumble'],
|
|
35
|
+
playEffect(type, params) {
|
|
36
|
+
if (type !== 'dual-rumble') {
|
|
37
|
+
return Promise.reject(new Error(`[driftengine] the native host plays no ${type} effect`));
|
|
38
|
+
}
|
|
39
|
+
finish('preempted');
|
|
40
|
+
const duration = params['duration'] ?? 0;
|
|
41
|
+
const delay = params['startDelay'] ?? 0;
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
settle = resolve;
|
|
44
|
+
const start = () => {
|
|
45
|
+
motors.rumble(params['strongMagnitude'] ?? 0, params['weakMagnitude'] ?? 0, duration);
|
|
46
|
+
timer = setTimeout(() => finish('complete'), duration);
|
|
47
|
+
};
|
|
48
|
+
if (delay > 0) timer = setTimeout(start, delay);
|
|
49
|
+
else start();
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
reset() {
|
|
53
|
+
finish('preempted');
|
|
54
|
+
motors.stopRumble();
|
|
55
|
+
return Promise.resolve('complete');
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put the browser's input event classes on the global a page reads them from, where the runtime
|
|
3
|
+
* has none, and hand back what takes them away again. Node has `Event` and none of these.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
FocusEvent,
|
|
8
|
+
KeyboardEvent,
|
|
9
|
+
MouseEvent,
|
|
10
|
+
PointerEvent,
|
|
11
|
+
UIEvent,
|
|
12
|
+
WheelEvent,
|
|
13
|
+
} from './uiEvents.ts';
|
|
14
|
+
import { Touch, TouchEvent, TouchList } from './uiTouch.ts';
|
|
15
|
+
|
|
16
|
+
/** A pad arriving or leaving, as `gamepadconnected` and `gamepaddisconnected` carry it. */
|
|
17
|
+
export class GamepadEvent extends Event {
|
|
18
|
+
readonly gamepad: unknown;
|
|
19
|
+
constructor(type: string, init: EventInit & { gamepad: unknown }) {
|
|
20
|
+
super(type, init);
|
|
21
|
+
this.gamepad = init.gamepad;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CLASSES = {
|
|
26
|
+
UIEvent,
|
|
27
|
+
FocusEvent,
|
|
28
|
+
MouseEvent,
|
|
29
|
+
WheelEvent,
|
|
30
|
+
PointerEvent,
|
|
31
|
+
KeyboardEvent,
|
|
32
|
+
Touch,
|
|
33
|
+
TouchList,
|
|
34
|
+
TouchEvent,
|
|
35
|
+
GamepadEvent,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function installEventClasses(
|
|
39
|
+
scope: Record<string, unknown> = globalThis as Record<string, unknown>,
|
|
40
|
+
): () => void {
|
|
41
|
+
const added = Object.entries(CLASSES).filter(([name]) => !(name in scope));
|
|
42
|
+
for (const [name, value] of added) scope[name] = value;
|
|
43
|
+
return () => {
|
|
44
|
+
for (const [name] of added) delete scope[name];
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's own files, answered from disk: the `fetch` a scene calls, under a host.
|
|
3
|
+
*
|
|
4
|
+
* **A published scene fetches by a path relative to its page**: the showroom's model, the voxel
|
|
5
|
+
* sandbox's tiles. A browser resolves that against the server that sent the page, and a host has no
|
|
6
|
+
* server — so it answers those paths from the directory the dev server serves (`demo/dev/public`),
|
|
7
|
+
* and a `file:` URL, which is what `new URL(path, import.meta.url)` makes, as the file it names.
|
|
8
|
+
* Anything carrying a scheme of its own is the network's, and goes to the platform's `fetch`.
|
|
9
|
+
*
|
|
10
|
+
* **A missing file is a 404, not a throw**, because that is what the scenes already handle: the
|
|
11
|
+
* sandbox paints a missing tile magenta, and the showroom asks with `HEAD` whether a model is there.
|
|
12
|
+
* A path that climbs out of the served directory is a 404 too, as it would be from a server.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
16
|
+
import { resolve, sep } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
20
|
+
|
|
21
|
+
const TYPES: Record<string, string> = {
|
|
22
|
+
png: 'image/png',
|
|
23
|
+
json: 'application/json',
|
|
24
|
+
drft: 'application/octet-stream',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function typeOf(path: string): string {
|
|
28
|
+
return TYPES[path.slice(path.lastIndexOf('.') + 1).toLowerCase()] ?? 'application/octet-stream';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The file a request names, or null where it is the network's or outside what is served. */
|
|
32
|
+
function pathOf(root: string, url: string): string | null {
|
|
33
|
+
if (url.startsWith('file:')) return fileURLToPath(url);
|
|
34
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return null;
|
|
35
|
+
const clean = url.split(/[?#]/)[0] ?? '';
|
|
36
|
+
const path = resolve(root, `.${clean.startsWith('/') ? '' : '/'}${clean}`);
|
|
37
|
+
const inside = path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
38
|
+
return inside ? path : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hostFetch(root: string, network: Fetch): Fetch {
|
|
42
|
+
const served = resolve(root);
|
|
43
|
+
return async (input, init) => {
|
|
44
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
45
|
+
const method = (
|
|
46
|
+
init?.method ?? (input instanceof Request ? input.method : 'GET')
|
|
47
|
+
).toUpperCase();
|
|
48
|
+
const path = pathOf(served, url);
|
|
49
|
+
if (path === null) return network(input, init);
|
|
50
|
+
if (path === '') return new Response(null, { status: 404 });
|
|
51
|
+
try {
|
|
52
|
+
const found = await stat(path);
|
|
53
|
+
if (!found.isFile()) return new Response(null, { status: 404 });
|
|
54
|
+
const headers = { 'content-type': typeOf(path), 'content-length': String(found.size) };
|
|
55
|
+
if (method === 'HEAD') return new Response(null, { status: 200, headers });
|
|
56
|
+
return new Response(await readFile(path), { status: 200, headers });
|
|
57
|
+
} catch {
|
|
58
|
+
return new Response(null, { status: 404 });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Fullscreen API over the host's window: `requestFullscreen` on the canvas, `exitFullscreen`
|
|
3
|
+
* and `fullscreenElement` on the document, and `fullscreenchange` and `fullscreenerror` heard
|
|
4
|
+
* there, as a page expects them.
|
|
5
|
+
*
|
|
6
|
+
* **Chrome's refusals, word for word, measured 2026-09-19 in Chrome 151**: a request with no
|
|
7
|
+
* gesture is `TypeError: Permissions check failed`, and leaving when nothing is fullscreen is
|
|
8
|
+
* `TypeError: Failed to execute 'exitFullscreen' on 'Document': Document not active`. A granted
|
|
9
|
+
* request spends the gesture (`activation.ts`).
|
|
10
|
+
*
|
|
11
|
+
* **Element fullscreen is the window's fullscreen.** The canvas fills the window, so the window is
|
|
12
|
+
* what goes fullscreen, through SDL; and when anything else takes the window out — the shell's
|
|
13
|
+
* bridge, the window manager, Escape — the element leaves with it and the page is told.
|
|
14
|
+
*
|
|
15
|
+
* What it gives up: the change is heard as soon as SDL is asked, rather than on the frame after the
|
|
16
|
+
* window has been resized, which is when Chrome sends it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { UserActivation } from './activation.ts';
|
|
20
|
+
import type { NativeCanvas } from './canvas.ts';
|
|
21
|
+
|
|
22
|
+
export class PageFullscreen {
|
|
23
|
+
element: NativeCanvas | null = null;
|
|
24
|
+
/** The platform's half: put the window in fullscreen or take it out, false when it cannot. */
|
|
25
|
+
setter: ((on: boolean) => boolean) | null = null;
|
|
26
|
+
/** Told after each change, for what reads it: `(display-mode: fullscreen)`. */
|
|
27
|
+
changed: () => void = () => undefined;
|
|
28
|
+
|
|
29
|
+
constructor(private readonly activation: UserActivation) {}
|
|
30
|
+
|
|
31
|
+
request(canvas: NativeCanvas): Promise<void> {
|
|
32
|
+
if (!this.activation.isActive) {
|
|
33
|
+
return this.refuse(canvas, new TypeError('Permissions check failed'));
|
|
34
|
+
}
|
|
35
|
+
this.activation.consume();
|
|
36
|
+
if (this.element === canvas) return Promise.resolve();
|
|
37
|
+
if (this.setter === null || !this.setter(true)) {
|
|
38
|
+
return this.refuse(canvas, new TypeError('Fullscreen request denied'));
|
|
39
|
+
}
|
|
40
|
+
this.element = canvas;
|
|
41
|
+
canvas.dispatchEvent(new Event('fullscreenchange', { bubbles: true, composed: true }));
|
|
42
|
+
this.changed();
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
exit(): Promise<void> {
|
|
47
|
+
const element = this.element;
|
|
48
|
+
if (element === null) {
|
|
49
|
+
return Promise.reject(
|
|
50
|
+
new TypeError("Failed to execute 'exitFullscreen' on 'Document': Document not active"),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
this.setter?.(false);
|
|
54
|
+
this.lost();
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The window is no longer fullscreen, whoever took it out, so neither is the element. */
|
|
59
|
+
lost(): void {
|
|
60
|
+
const element = this.element;
|
|
61
|
+
if (element === null) return;
|
|
62
|
+
this.element = null;
|
|
63
|
+
element.dispatchEvent(new Event('fullscreenchange', { bubbles: true, composed: true }));
|
|
64
|
+
this.changed();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private refuse(canvas: NativeCanvas, error: TypeError): Promise<void> {
|
|
68
|
+
canvas.dispatchEvent(new Event('fullscreenerror', { bubbles: true, composed: true }));
|
|
69
|
+
return Promise.reject(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/game.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A game on the host: the host started, the game's native entry mounted on its canvas, and frames
|
|
3
|
+
* drawn until the window closes or the game quits.
|
|
4
|
+
*
|
|
5
|
+
* **The contract is one export, `mount(canvas)`.** In a browser a game's page finds its canvas in
|
|
6
|
+
* its own markup; here there is no markup, so the host hands the canvas over. Everything after that
|
|
7
|
+
* is the game's as it is in a browser: it builds its renderer on the canvas and drives its own loop
|
|
8
|
+
* with `requestAnimationFrame`, which the host's page runs once a frame before it paints. A game
|
|
9
|
+
* that already mounts onto a canvas it is given — as the engine's own scenes do — needs a file of a
|
|
10
|
+
* few lines and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* **`startupReport` prints when the first frame reached the screen**, in milliseconds since the
|
|
13
|
+
* process began, which is how the packaging target's cold start is measured.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type HostOptions, startHost } from './runtime.ts';
|
|
17
|
+
|
|
18
|
+
/** What a game's native entry exports. */
|
|
19
|
+
export interface GameEntry {
|
|
20
|
+
mount(canvas: HTMLCanvasElement): unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GameOptions extends HostOptions {
|
|
24
|
+
/** The game's native entry, imported once the host has started. */
|
|
25
|
+
readonly load: () => Promise<Partial<GameEntry>>;
|
|
26
|
+
readonly startupReport?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runGame(options: GameOptions): Promise<void> {
|
|
30
|
+
const host = startHost(options);
|
|
31
|
+
const entry = await options.load();
|
|
32
|
+
if (typeof entry.mount !== 'function') {
|
|
33
|
+
const has = Object.keys(entry).join(', ') || 'nothing';
|
|
34
|
+
throw new Error(
|
|
35
|
+
`[driftengine] the game's native entry exports no \`mount(canvas)\`; it exports ${has}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
await entry.mount(host.canvas as unknown as HTMLCanvasElement);
|
|
39
|
+
await host.run({
|
|
40
|
+
after: ({ drawn }) => {
|
|
41
|
+
if (options.startupReport === true && drawn === 0) {
|
|
42
|
+
console.log(`[driftengine] first frame after ${Math.round(performance.now())} ms`);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
await host.close();
|
|
47
|
+
/*
|
|
48
|
+
* **Ended here rather than left to end by itself.** A natural exit runs Dawn's finalizers over
|
|
49
|
+
* objects the engine has already destroyed, and one of them trips a reference-count assertion
|
|
50
|
+
* that takes the process down with it (measured 2026-09-18). Nothing is left for them to release.
|
|
51
|
+
*/
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
package/src/gamepads.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pads, as `navigator.getGamepads()` answers them and as Chrome shows them to a page.
|
|
3
|
+
*
|
|
4
|
+
* **The engine polls pads through the browser's API and is not changed for this host.** A
|
|
5
|
+
* controller SDL knows is the W3C's standard gamepad (`standardPad.ts`); a joystick it has no
|
|
6
|
+
* mapping for is a pad with none (`rawPad.ts`), which the engine reads as `mapping: 'unknown'` and
|
|
7
|
+
* by index. Both share Chrome's four slots.
|
|
8
|
+
*
|
|
9
|
+
* **Chrome shows a page no pad until a person uses one, and neither does this.** Chromium's rule,
|
|
10
|
+
* from its source (`device/gamepad/gamepad_user_gesture.cc`) and not measured, since the machine
|
|
11
|
+
* this was written on has no pad and the DevTools protocol cannot fake one: a button pressed, or an
|
|
12
|
+
* axis past 0.5, on any pad. Every pad is then shown and announced on the window with
|
|
13
|
+
* `gamepadconnected`; one arriving later is announced as it arrives, and one leaving with
|
|
14
|
+
* `gamepaddisconnected` and `connected` false. Before that a page sees four empty slots, which is
|
|
15
|
+
* what a game saying "press any button" is waiting out.
|
|
16
|
+
*
|
|
17
|
+
* **Rumble is SDL's** (`dualRumble.ts`): the first thing this host does that a browser cannot be
|
|
18
|
+
* relied on for.
|
|
19
|
+
*
|
|
20
|
+
* What it gives up: SDL is read when the page asks and every sixtieth of a second otherwise, rather
|
|
21
|
+
* than on a thread of its own as Chrome reads it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { GamepadEvent } from './eventClasses.ts';
|
|
25
|
+
import { type JoystickDevice, type JoystickInstance, RawPad } from './rawPad.ts';
|
|
26
|
+
import { type PadController, type PadDevice, StandardPad } from './standardPad.ts';
|
|
27
|
+
|
|
28
|
+
export type { JoystickDevice, JoystickInstance } from './rawPad.ts';
|
|
29
|
+
export type { PadController, PadDevice } from './standardPad.ts';
|
|
30
|
+
|
|
31
|
+
interface DeviceModule<Device, Opened> {
|
|
32
|
+
readonly devices: readonly Device[];
|
|
33
|
+
openDevice(device: Device): Opened;
|
|
34
|
+
on(event: 'deviceAdd' | 'deviceRemove', listener: (event: { device: Device }) => void): unknown;
|
|
35
|
+
}
|
|
36
|
+
/** SDL's controller module, and its joystick module, as far as this reads them. */
|
|
37
|
+
export type PadModule = DeviceModule<PadDevice, PadController>;
|
|
38
|
+
export type JoystickModule = DeviceModule<JoystickDevice, JoystickInstance>;
|
|
39
|
+
|
|
40
|
+
export interface GamepadOptions {
|
|
41
|
+
readonly joysticks?: JoystickModule;
|
|
42
|
+
/** Where `gamepadconnected` and `gamepaddisconnected` are told: the page's window. */
|
|
43
|
+
readonly events?: EventTarget;
|
|
44
|
+
readonly now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Pad = StandardPad | RawPad;
|
|
48
|
+
const SLOTS = 4;
|
|
49
|
+
/** Chromium's `kAxisMoveAmountThreshold`: an axis past this is a person, not drift. */
|
|
50
|
+
const AXIS_IS_A_PERSON = 0.5;
|
|
51
|
+
const POLL_MS = 1000 / 60;
|
|
52
|
+
|
|
53
|
+
export class HostGamepads {
|
|
54
|
+
private readonly slots: (Pad | null)[] = Array.from({ length: SLOTS }, () => null);
|
|
55
|
+
private readonly empty: null[] = Array.from({ length: SLOTS }, () => null);
|
|
56
|
+
private shown = false;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly controllers: PadModule,
|
|
60
|
+
private readonly options: GamepadOptions = {},
|
|
61
|
+
) {
|
|
62
|
+
for (const device of controllers.devices) this.addController(device);
|
|
63
|
+
controllers.on('deviceAdd', ({ device }) => this.addController(device));
|
|
64
|
+
controllers.on('deviceRemove', ({ device }) => this.remove(device.id, StandardPad));
|
|
65
|
+
const joysticks = options.joysticks;
|
|
66
|
+
if (joysticks === undefined) return;
|
|
67
|
+
for (const device of joysticks.devices) this.addJoystick(joysticks, device);
|
|
68
|
+
joysticks.on('deviceAdd', ({ device }) => this.addJoystick(joysticks, device));
|
|
69
|
+
joysticks.on('deviceRemove', ({ device }) => this.remove(device.id, RawPad));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The four slots, each pad read afresh — or four empty ones, until a person has used a pad. */
|
|
73
|
+
readonly getGamepads = (): (Pad | null)[] => {
|
|
74
|
+
this.poll();
|
|
75
|
+
return this.shown ? this.slots : this.empty;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Read every pad, and show them all the first time one has been used. */
|
|
79
|
+
poll(): void {
|
|
80
|
+
const now = (this.options.now ?? (() => performance.now()))();
|
|
81
|
+
for (const pad of this.slots) pad?.read(now);
|
|
82
|
+
if (this.shown || !this.slots.some(used)) return;
|
|
83
|
+
this.shown = true;
|
|
84
|
+
for (const pad of this.slots) if (pad !== null) this.announce('gamepadconnected', pad);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Answer `navigator.getGamepads`, read pads while nothing asks, and hand back what stops both. */
|
|
88
|
+
install(scope: { navigator?: object } = globalThis as { navigator?: object }): () => void {
|
|
89
|
+
/* Node has a `navigator` of its own, behind a getter, so it is added to rather than replaced. */
|
|
90
|
+
if (scope.navigator === undefined) {
|
|
91
|
+
Object.defineProperty(scope, 'navigator', { value: {}, configurable: true });
|
|
92
|
+
}
|
|
93
|
+
const navigator = scope.navigator as object;
|
|
94
|
+
const had = Object.getOwnPropertyDescriptor(navigator, 'getGamepads');
|
|
95
|
+
Object.defineProperty(navigator, 'getGamepads', {
|
|
96
|
+
value: this.getGamepads,
|
|
97
|
+
configurable: true,
|
|
98
|
+
writable: true,
|
|
99
|
+
});
|
|
100
|
+
const reading = setInterval(() => this.poll(), POLL_MS);
|
|
101
|
+
reading.unref();
|
|
102
|
+
return () => {
|
|
103
|
+
clearInterval(reading);
|
|
104
|
+
if (had === undefined) delete (navigator as Record<string, unknown>)['getGamepads'];
|
|
105
|
+
else Object.defineProperty(navigator, 'getGamepads', had);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private addController(device: PadDevice): void {
|
|
110
|
+
if (this.slotOf(device.id, StandardPad) >= 0) return;
|
|
111
|
+
/* Listed first as a bare joystick, it is a controller now that SDL has its mapping. */
|
|
112
|
+
this.remove(device.id, RawPad, false);
|
|
113
|
+
this.place((at) => new StandardPad(at, this.controllers.openDevice(device)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private addJoystick(joysticks: JoystickModule, device: JoystickDevice): void {
|
|
117
|
+
/* SDL lists every controller among its joysticks too; that one is shown as a controller. */
|
|
118
|
+
if (this.controllers.devices.some((controller) => controller.id === device.id)) return;
|
|
119
|
+
if (this.slotOf(device.id, RawPad) >= 0) return;
|
|
120
|
+
const source = joysticks.openDevice(device);
|
|
121
|
+
/*
|
|
122
|
+
* **A device with nothing to steer by is not a gamepad, whatever SDL lists it among.**
|
|
123
|
+
*
|
|
124
|
+
* A keyboard exposes a second HID endpoint for its power and media keys, and SDL enumerates
|
|
125
|
+
* that among the joysticks: a maintainer's arrived as *"Keychron K8 Pro System Control"*, took
|
|
126
|
+
* the first pad slot, and the engine warned it could not vouch for the layout while the real
|
|
127
|
+
* controller sat behind it. Chrome does not offer a keyboard as a gamepad.
|
|
128
|
+
*
|
|
129
|
+
* **The rule is what a pad can be steered with**, rather than a name or a vendor, which would
|
|
130
|
+
* be a list to maintain forever. No axes *and* no hats is nothing a game can read as a
|
|
131
|
+
* direction — a stick with only buttons still has a hat, which is why it is both and not
|
|
132
|
+
* either. **What would make this wrong** is a pad whose axes SDL reports as absent until it is
|
|
133
|
+
* first moved; none seen, and the tell would be a real controller that never appears.
|
|
134
|
+
*/
|
|
135
|
+
if (source.axes.length === 0 && source.hats.length === 0) {
|
|
136
|
+
source.close();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.place((at) => new RawPad(at, source));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private place(make: (at: number) => Pad): void {
|
|
143
|
+
const at = this.slots.indexOf(null);
|
|
144
|
+
if (at < 0) return;
|
|
145
|
+
const pad = make(at);
|
|
146
|
+
this.slots[at] = pad;
|
|
147
|
+
if (this.shown) this.announce('gamepadconnected', pad);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private remove(id: number, kind: typeof StandardPad | typeof RawPad, announce = true): void {
|
|
151
|
+
const at = this.slotOf(id, kind);
|
|
152
|
+
const pad = this.slots[at];
|
|
153
|
+
if (pad === undefined || pad === null) return;
|
|
154
|
+
/*
|
|
155
|
+
* **A handle the platform has already taken away must not take the process with it.**
|
|
156
|
+
*
|
|
157
|
+
* SDL lists every controller among its joysticks too, so a pad arrives first as a bare
|
|
158
|
+
* joystick and again as a controller once SDL has a mapping for it — and the promotion closes
|
|
159
|
+
* the joystick handle that SDL has *already* invalidated. Its own `close` then throws
|
|
160
|
+
* `Cannot read properties of undefined (reading 'delete')` from inside `joystick-instance.js`,
|
|
161
|
+
* out of the poll callback, and the process ends: a maintainer turned a pad on and the scene
|
|
162
|
+
* died (`@kmamal/sdl` 0.11.13, Linux, 2026-09-20).
|
|
163
|
+
*
|
|
164
|
+
* **What it gives up:** a close that fails for some *other* reason is swallowed too, and a
|
|
165
|
+
* device handle may leak until the process ends. That is the right trade for a host — after
|
|
166
|
+
* boot the frame loop never throws — and the slot is freed either way, which is the part the
|
|
167
|
+
* engine can see.
|
|
168
|
+
*/
|
|
169
|
+
try {
|
|
170
|
+
pad.source.close();
|
|
171
|
+
} catch {
|
|
172
|
+
/* Already gone. The slot below is what matters. */
|
|
173
|
+
}
|
|
174
|
+
pad.connected = false;
|
|
175
|
+
this.slots[at] = null;
|
|
176
|
+
if (this.shown && announce) this.announce('gamepaddisconnected', pad);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private slotOf(id: number, kind: typeof StandardPad | typeof RawPad): number {
|
|
180
|
+
return this.slots.findIndex((pad) => pad instanceof kind && pad.source.device.id === id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private announce(type: string, pad: Pad): void {
|
|
184
|
+
this.options.events?.dispatchEvent(new GamepadEvent(type, { gamepad: pad }));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether a person is using `pad`: a button pressed, or an axis past drift. */
|
|
189
|
+
function used(pad: Pad | null): boolean {
|
|
190
|
+
if (pad === null) return false;
|
|
191
|
+
return (
|
|
192
|
+
pad.buttons.some((button) => button.pressed) ||
|
|
193
|
+
pad.axes.some((axis) => Math.abs(axis) > AXIS_IS_A_PERSON)
|
|
194
|
+
);
|
|
195
|
+
}
|
package/src/globals.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The globals the engine reads, installed by the host before any engine code runs.
|
|
3
|
+
*
|
|
4
|
+
* **Every one is a place the engine reaches outside itself**, and the list is short on purpose:
|
|
5
|
+
* `AGENTS.md` forbids a direct platform call, `scripts/platform.test.mjs` holds that for the
|
|
6
|
+
* engine's packages, and what is left is the browser's own WebGPU entry point, which the backend
|
|
7
|
+
* reads as `navigator.gpu` because that is what WebGPU is in a page. A gap Task 3 finds is added
|
|
8
|
+
* here with the scene that found it, so this file is the host's list of what the engine assumed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `navigator.gpu`. Node has a `navigator` of its own (with `hardwareConcurrency`, which the engine's
|
|
13
|
+
* thread pools read), so the property goes onto it rather than replacing it.
|
|
14
|
+
*/
|
|
15
|
+
export function installGpu(gpu: GPU): void {
|
|
16
|
+
const scope = globalThis as { navigator?: object };
|
|
17
|
+
if (scope.navigator === undefined) {
|
|
18
|
+
Object.defineProperty(globalThis, 'navigator', { value: { gpu }, configurable: true });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(scope.navigator, 'gpu', { value: gpu, configurable: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `location`, with only the query a scene reads its options from.
|
|
26
|
+
*
|
|
27
|
+
* **Demo code, not engine code, reads it**: the voxel sandbox, the showroom, the day clock and the
|
|
28
|
+
* city each take their options from the page's address (`?radius=`, `?exposure=`, `?at=`), which
|
|
29
|
+
* is how the dev harness and a capture hold a scene still. The engine's own read, in
|
|
30
|
+
* `createRenderer`, is already optional. A host passes the same query a browser capture would, so
|
|
31
|
+
* the two draw the same scene.
|
|
32
|
+
*/
|
|
33
|
+
export function installLocation(search: string): void {
|
|
34
|
+
const query = search === '' || search.startsWith('?') ? search : `?${search}`;
|
|
35
|
+
Object.defineProperty(globalThis, 'location', {
|
|
36
|
+
value: { search: query, href: `native://host/${query}`, pathname: '/' },
|
|
37
|
+
configurable: true,
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/gpu.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `navigator.gpu` for a host with no browser: Dawn's instance, answering as the browser does.
|
|
3
|
+
*
|
|
4
|
+
* **The engine is not told it is on a host.** Its WebGPU backend asks `navigator.gpu` for an
|
|
5
|
+
* adapter and for the format a canvas prefers, and nothing else; this answers both from Dawn, the
|
|
6
|
+
* implementation inside Chrome, so the device the engine gets is the one it would have been given
|
|
7
|
+
* in a browser on the same machine.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { adaptDevice } from './device.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The format a canvas prefers, answered as Chrome answers it on this machine.
|
|
14
|
+
*
|
|
15
|
+
* **The engine builds every pipeline that writes the frame against this answer**, and Dawn's own
|
|
16
|
+
* instance says `bgra8unorm` where Chrome says `rgba8unorm`. The host owns the texture the frame
|
|
17
|
+
* lands in, so it can answer either; it answers the browser's, and the two hosts build the same
|
|
18
|
+
* pipelines. What it gives up: on a machine where Chrome prefers `bgra8unorm` the two hosts
|
|
19
|
+
* differ in channel order, which is the same picture and a different pipeline key.
|
|
20
|
+
*/
|
|
21
|
+
export const PREFERRED_FORMAT: GPUTextureFormat = 'rgba8unorm';
|
|
22
|
+
|
|
23
|
+
/** What this needs from Dawn's instance: the adapter request, and nothing it says about canvases. */
|
|
24
|
+
export interface DawnInstance {
|
|
25
|
+
requestAdapter(options?: GPURequestAdapterOptions): Promise<GPUAdapter | null>;
|
|
26
|
+
getPreferredCanvasFormat?(): GPUTextureFormat;
|
|
27
|
+
readonly wgslLanguageFeatures?: WGSLLanguageFeatures;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The adapter the engine is handed, whose devices come out adapted: see `device.ts`. A proxy rather
|
|
32
|
+
* than a patch, because the adapter is the binding's object and its other members are read as they
|
|
33
|
+
* are.
|
|
34
|
+
*/
|
|
35
|
+
function hostAdapter(adapter: GPUAdapter): GPUAdapter {
|
|
36
|
+
return new Proxy(adapter, {
|
|
37
|
+
get(target, key) {
|
|
38
|
+
if (key === 'requestDevice') {
|
|
39
|
+
return async (descriptor?: GPUDeviceDescriptor) =>
|
|
40
|
+
adaptDevice(await target.requestDevice(descriptor));
|
|
41
|
+
}
|
|
42
|
+
const value: unknown = Reflect.get(target, key, target);
|
|
43
|
+
return typeof value === 'function' ? (value as () => unknown).bind(target) : value;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The object a host installs as `navigator.gpu`. */
|
|
49
|
+
export function nativeGpu(instance: DawnInstance): GPU {
|
|
50
|
+
const gpu = {
|
|
51
|
+
requestAdapter: async (options?: GPURequestAdapterOptions) => {
|
|
52
|
+
const adapter = await instance.requestAdapter(options);
|
|
53
|
+
return adapter === null ? null : hostAdapter(adapter);
|
|
54
|
+
},
|
|
55
|
+
getPreferredCanvasFormat: (): GPUTextureFormat => PREFERRED_FORMAT,
|
|
56
|
+
wgslLanguageFeatures: instance.wgslLanguageFeatures ?? new Set<string>(),
|
|
57
|
+
};
|
|
58
|
+
return gpu as unknown as GPU;
|
|
59
|
+
}
|