@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
package/src/images.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createImageBitmap`, for a host with no browser to decode an image.
|
|
3
|
+
*
|
|
4
|
+
* **A scene's images reach the engine through it**: a model's textures are PNG and JPEG bytes the
|
|
5
|
+
* loader hands to `createImageBitmap`, and the bitmap it gets back goes to the device through
|
|
6
|
+
* `copyExternalImageToTexture`. Node has neither, so the host decodes, and its queue (see
|
|
7
|
+
* `device.ts`) writes a host bitmap's bytes where the browser would have copied an image.
|
|
8
|
+
*
|
|
9
|
+
* **The decoders are chosen to give Chrome's bytes.** PNG is lossless, so this repository's own
|
|
10
|
+
* decoder (`packages/core/scripts/png.mjs`) gives them by definition. JPEG goes through
|
|
11
|
+
* `@jsquash/jpeg`, MozJPEG compiled to WebAssembly — libjpeg-turbo, the decoder Chrome uses — which
|
|
12
|
+
* decodes the showroom car's five JPEG textures to Chrome's bytes exactly: 0 of 6.07 million pixels,
|
|
13
|
+
* measured 2026-09-19 against `createImageBitmap` in Chrome on the same bytes. A JavaScript JPEG
|
|
14
|
+
* decoder was the alternative and would have differed, because the inverse transform is not
|
|
15
|
+
* specified to the bit.
|
|
16
|
+
*
|
|
17
|
+
* What it gives up: WebP is not decoded (no scene the host runs needs it yet, and it is refused by
|
|
18
|
+
* name), and a resized decode is declined — see `hostCreateImageBitmap`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
import decodeJpeg, { init as initJpeg } from '@jsquash/jpeg/decode.js';
|
|
25
|
+
|
|
26
|
+
import { decodePng } from '@driftengine/core/scripts/png.mjs';
|
|
27
|
+
|
|
28
|
+
/** What a host hands back for an image: its size and four bytes a pixel, top row first. */
|
|
29
|
+
export interface HostBitmap {
|
|
30
|
+
readonly width: number;
|
|
31
|
+
readonly height: number;
|
|
32
|
+
/** Straight alpha, as decoded. */
|
|
33
|
+
readonly data: Uint8ClampedArray;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a browser would be holding this premultiplied, which it does unless asked not to
|
|
36
|
+
* (`premultiplyAlpha: 'none'`). The data stays straight; the queue does what Chrome's copy does
|
|
37
|
+
* with a premultiplied image (see `device.ts`), so a transparent texel's colour is lost where
|
|
38
|
+
* Chrome loses it.
|
|
39
|
+
*/
|
|
40
|
+
readonly premultiplied: boolean;
|
|
41
|
+
close(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HOST_BITMAP = Symbol('driftengine host bitmap');
|
|
45
|
+
|
|
46
|
+
export function isHostBitmap(value: unknown): value is HostBitmap {
|
|
47
|
+
return typeof value === 'object' && value !== null && HOST_BITMAP in value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bitmap(
|
|
51
|
+
width: number,
|
|
52
|
+
height: number,
|
|
53
|
+
data: Uint8ClampedArray,
|
|
54
|
+
premultiplied: boolean,
|
|
55
|
+
): HostBitmap {
|
|
56
|
+
return {
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
data,
|
|
60
|
+
premultiplied,
|
|
61
|
+
close: () => undefined,
|
|
62
|
+
[HOST_BITMAP]: true,
|
|
63
|
+
} as HostBitmap;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Four bytes a pixel out of three or four. */
|
|
67
|
+
function rgba(pixels: Uint8Array, channels: number, count: number): Uint8ClampedArray {
|
|
68
|
+
const out = new Uint8ClampedArray(count * 4);
|
|
69
|
+
if (channels === 4) {
|
|
70
|
+
out.set(pixels.subarray(0, count * 4));
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
for (let at = 0; at < count; at += 1) {
|
|
74
|
+
out[at * 4] = pixels[at * channels] as number;
|
|
75
|
+
out[at * 4 + 1] = pixels[at * channels + 1] as number;
|
|
76
|
+
out[at * 4 + 2] = pixels[at * channels + 2] as number;
|
|
77
|
+
out[at * 4 + 3] = 255;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let jpegReady: Promise<void> | null = null;
|
|
83
|
+
|
|
84
|
+
/** MozJPEG's module, compiled once from the file its package ships. */
|
|
85
|
+
function readyJpeg(): Promise<void> {
|
|
86
|
+
if (jpegReady === null) {
|
|
87
|
+
const wasm = createRequire(import.meta.url).resolve('@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm');
|
|
88
|
+
jpegReady = WebAssembly.compile(readFileSync(wasm)).then((module) => initJpeg(module));
|
|
89
|
+
}
|
|
90
|
+
return jpegReady;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decode a blob or take `ImageData` as it is.
|
|
95
|
+
*
|
|
96
|
+
* **Decoded straight, with no colour conversion**, which is what the engine asks for where it asks
|
|
97
|
+
* (`premultiplyAlpha: 'none'`, `colorSpaceConversion: 'none'`). Where it does not ask, a browser
|
|
98
|
+
* holds the image premultiplied, and the bitmap says so for the copy to act on. **A resized decode is declined by name**: the model loader asks
|
|
99
|
+
* for a small preview before the sharp image and drops it if it fails, and a preview resampled by
|
|
100
|
+
* another filter than Chrome's would put a picture on the screen that no browser drew.
|
|
101
|
+
*/
|
|
102
|
+
export async function hostCreateImageBitmap(
|
|
103
|
+
source: Blob | ImageData,
|
|
104
|
+
options: ImageBitmapOptions = {},
|
|
105
|
+
): Promise<HostBitmap> {
|
|
106
|
+
if (options.resizeWidth !== undefined || options.resizeHeight !== undefined) {
|
|
107
|
+
throw new Error('[driftengine] the native host does not resample while it decodes');
|
|
108
|
+
}
|
|
109
|
+
const premultiplied = options.premultiplyAlpha !== 'none';
|
|
110
|
+
if (!(source instanceof Blob)) {
|
|
111
|
+
return bitmap(source.width, source.height, new Uint8ClampedArray(source.data), premultiplied);
|
|
112
|
+
}
|
|
113
|
+
const bytes = new Uint8Array(await source.arrayBuffer());
|
|
114
|
+
if (source.type === 'image/png') {
|
|
115
|
+
const png = decodePng(Buffer.from(bytes));
|
|
116
|
+
const pixels = rgba(png.pixels, png.channels, png.width * png.height);
|
|
117
|
+
return bitmap(png.width, png.height, pixels, premultiplied);
|
|
118
|
+
}
|
|
119
|
+
if (source.type === 'image/jpeg') {
|
|
120
|
+
await readyJpeg();
|
|
121
|
+
const image = await decodeJpeg(bytes.buffer);
|
|
122
|
+
return bitmap(image.width, image.height, new Uint8ClampedArray(image.data), premultiplied);
|
|
123
|
+
}
|
|
124
|
+
throw new Error(
|
|
125
|
+
`[driftengine] the native host cannot decode ${source.type || 'an untyped blob'}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The image globals a scene reads: `createImageBitmap`, and `ImageData`, which Node lacks and which
|
|
131
|
+
* both the model loader and MozJPEG's module construct.
|
|
132
|
+
*/
|
|
133
|
+
export function installImages(): void {
|
|
134
|
+
const scope = globalThis as Record<string, unknown>;
|
|
135
|
+
scope['createImageBitmap'] = hostCreateImageBitmap;
|
|
136
|
+
if (scope['ImageData'] === undefined) {
|
|
137
|
+
scope['ImageData'] = class {
|
|
138
|
+
readonly data: Uint8ClampedArray;
|
|
139
|
+
readonly width: number;
|
|
140
|
+
readonly height: number;
|
|
141
|
+
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
|
142
|
+
this.data = data;
|
|
143
|
+
this.width = width;
|
|
144
|
+
this.height = height;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
|
|
2
|
+
/**
|
|
3
|
+
* The engine on a native window and a native WebGPU device: Node, Dawn and SDL, with no browser
|
|
4
|
+
* between them.
|
|
5
|
+
*
|
|
6
|
+
* **Two calls are the whole surface.** `runGame` is what a packaged game's generated entry calls:
|
|
7
|
+
* it starts the host, mounts the game's native entry on the canvas, and draws until the window
|
|
8
|
+
* closes. `startHost` is the same start without a game, for a tool that drives its own frames —
|
|
9
|
+
* the engine repository's scene runner and editor runner are two.
|
|
10
|
+
*
|
|
11
|
+
* Everything a page reads around the canvas — events, pointer lock, fullscreen, gamepads, audio,
|
|
12
|
+
* storage — the host installs as a browser would have it, so the engine and the game run unchanged.
|
|
13
|
+
* The modules that do it are in `src/` and are private.
|
|
14
|
+
*
|
|
15
|
+
* **One capability a page has no equivalent of is offered as well**: `tileFiles`, the texture
|
|
16
|
+
* streamer's tiles read from a directory on disk, for a game's native entry to hand its streamer
|
|
17
|
+
* where a page would fetch them over HTTP.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { runGame } from './game.ts';
|
|
21
|
+
export type { GameEntry, GameOptions } from './game.ts';
|
|
22
|
+
export { configDirectory, startHost } from './runtime.ts';
|
|
23
|
+
export type { FrameCall, HostOptions, NativeHost, RunOptions } from './runtime.ts';
|
|
24
|
+
export { tileFiles } from './tileFiles.ts';
|
package/src/keyCodes.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key, named as a browser names it, from the scancode SDL reports.
|
|
3
|
+
*
|
|
4
|
+
* **`code` is the name that matters**: the engine binds by it and so does every game on it, because
|
|
5
|
+
* it names a key's position rather than what the layout prints on it. SDL's scancode is the USB
|
|
6
|
+
* keyboard usage for that position, and the W3C table of codes is written against the same usages,
|
|
7
|
+
* so the two meet by number. `key` is what the key types, which a text field wants and a game
|
|
8
|
+
* mostly does not.
|
|
9
|
+
*
|
|
10
|
+
* What it gives up: keys a game does not bind — media keys, the international keys beyond the ISO
|
|
11
|
+
* backslash, a keypad's rarer members — have no `code` here and arrive as `Unidentified` rather
|
|
12
|
+
* than as a guess. Usage 0x32, the ISO hash key most boards report as 0x31 anyway, is one of them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const CODES = new Map<number, string>();
|
|
16
|
+
for (let at = 0; at < 26; at += 1) CODES.set(4 + at, `Key${String.fromCharCode(65 + at)}`);
|
|
17
|
+
for (let at = 0; at < 9; at += 1) CODES.set(30 + at, `Digit${at + 1}`);
|
|
18
|
+
CODES.set(39, 'Digit0');
|
|
19
|
+
for (let at = 0; at < 12; at += 1) CODES.set(58 + at, `F${at + 1}`);
|
|
20
|
+
for (let at = 0; at < 9; at += 1) CODES.set(89 + at, `Numpad${at + 1}`);
|
|
21
|
+
for (const [usage, code] of [
|
|
22
|
+
[40, 'Enter'],
|
|
23
|
+
[41, 'Escape'],
|
|
24
|
+
[42, 'Backspace'],
|
|
25
|
+
[43, 'Tab'],
|
|
26
|
+
[44, 'Space'],
|
|
27
|
+
[45, 'Minus'],
|
|
28
|
+
[46, 'Equal'],
|
|
29
|
+
[47, 'BracketLeft'],
|
|
30
|
+
[48, 'BracketRight'],
|
|
31
|
+
[49, 'Backslash'],
|
|
32
|
+
[51, 'Semicolon'],
|
|
33
|
+
[52, 'Quote'],
|
|
34
|
+
[53, 'Backquote'],
|
|
35
|
+
[54, 'Comma'],
|
|
36
|
+
[55, 'Period'],
|
|
37
|
+
[56, 'Slash'],
|
|
38
|
+
[57, 'CapsLock'],
|
|
39
|
+
[70, 'PrintScreen'],
|
|
40
|
+
[71, 'ScrollLock'],
|
|
41
|
+
[72, 'Pause'],
|
|
42
|
+
[73, 'Insert'],
|
|
43
|
+
[74, 'Home'],
|
|
44
|
+
[75, 'PageUp'],
|
|
45
|
+
[76, 'Delete'],
|
|
46
|
+
[77, 'End'],
|
|
47
|
+
[78, 'PageDown'],
|
|
48
|
+
[79, 'ArrowRight'],
|
|
49
|
+
[80, 'ArrowLeft'],
|
|
50
|
+
[81, 'ArrowDown'],
|
|
51
|
+
[82, 'ArrowUp'],
|
|
52
|
+
[83, 'NumLock'],
|
|
53
|
+
[84, 'NumpadDivide'],
|
|
54
|
+
[85, 'NumpadMultiply'],
|
|
55
|
+
[86, 'NumpadSubtract'],
|
|
56
|
+
[87, 'NumpadAdd'],
|
|
57
|
+
[88, 'NumpadEnter'],
|
|
58
|
+
[98, 'Numpad0'],
|
|
59
|
+
[99, 'NumpadDecimal'],
|
|
60
|
+
[100, 'IntlBackslash'],
|
|
61
|
+
[101, 'ContextMenu'],
|
|
62
|
+
[103, 'NumpadEqual'],
|
|
63
|
+
[224, 'ControlLeft'],
|
|
64
|
+
[225, 'ShiftLeft'],
|
|
65
|
+
[226, 'AltLeft'],
|
|
66
|
+
[227, 'MetaLeft'],
|
|
67
|
+
[228, 'ControlRight'],
|
|
68
|
+
[229, 'ShiftRight'],
|
|
69
|
+
[230, 'AltRight'],
|
|
70
|
+
[231, 'MetaRight'],
|
|
71
|
+
] as const) {
|
|
72
|
+
CODES.set(usage, code);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `KeyboardEvent.key` for the keys that type nothing, by position. */
|
|
76
|
+
const NAMED = new Map<number, string>([
|
|
77
|
+
[40, 'Enter'],
|
|
78
|
+
[88, 'Enter'],
|
|
79
|
+
[41, 'Escape'],
|
|
80
|
+
[42, 'Backspace'],
|
|
81
|
+
[43, 'Tab'],
|
|
82
|
+
[44, ' '],
|
|
83
|
+
[57, 'CapsLock'],
|
|
84
|
+
[70, 'PrintScreen'],
|
|
85
|
+
[71, 'ScrollLock'],
|
|
86
|
+
[72, 'Pause'],
|
|
87
|
+
[73, 'Insert'],
|
|
88
|
+
[74, 'Home'],
|
|
89
|
+
[75, 'PageUp'],
|
|
90
|
+
[76, 'Delete'],
|
|
91
|
+
[77, 'End'],
|
|
92
|
+
[78, 'PageDown'],
|
|
93
|
+
[79, 'ArrowRight'],
|
|
94
|
+
[80, 'ArrowLeft'],
|
|
95
|
+
[81, 'ArrowDown'],
|
|
96
|
+
[82, 'ArrowUp'],
|
|
97
|
+
[83, 'NumLock'],
|
|
98
|
+
[101, 'ContextMenu'],
|
|
99
|
+
[224, 'Control'],
|
|
100
|
+
[228, 'Control'],
|
|
101
|
+
[225, 'Shift'],
|
|
102
|
+
[229, 'Shift'],
|
|
103
|
+
[226, 'Alt'],
|
|
104
|
+
[230, 'Alt'],
|
|
105
|
+
[227, 'Meta'],
|
|
106
|
+
[231, 'Meta'],
|
|
107
|
+
]);
|
|
108
|
+
for (let at = 0; at < 12; at += 1) NAMED.set(58 + at, `F${at + 1}`);
|
|
109
|
+
|
|
110
|
+
/** The position's name, or null for one this table does not name. */
|
|
111
|
+
export function codeOf(scancode: number): string | null {
|
|
112
|
+
return CODES.get(scancode) ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What the key types, from SDL's name for it: the character, shifted where it is a letter and
|
|
117
|
+
* shift is held, or the browser's name for a key that types nothing.
|
|
118
|
+
*/
|
|
119
|
+
export function keyOf(key: string | null, scancode: number, shifted: boolean): string {
|
|
120
|
+
if (key !== null && key.length === 1) {
|
|
121
|
+
return shifted && key >= 'a' && key <= 'z' ? key.toUpperCase() : key;
|
|
122
|
+
}
|
|
123
|
+
return NAMED.get(scancode) ?? 'Unidentified';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The legacy `keyCode` a browser still sends on `keydown` and `keyup`: the Windows virtual-key code
|
|
128
|
+
* for the position, which Chrome sends on every platform.
|
|
129
|
+
*/
|
|
130
|
+
const LEGACY = new Map<string, number>([
|
|
131
|
+
['Backspace', 8],
|
|
132
|
+
['Tab', 9],
|
|
133
|
+
['Enter', 13],
|
|
134
|
+
['NumpadEnter', 13],
|
|
135
|
+
['ShiftLeft', 16],
|
|
136
|
+
['ShiftRight', 16],
|
|
137
|
+
['ControlLeft', 17],
|
|
138
|
+
['ControlRight', 17],
|
|
139
|
+
['AltLeft', 18],
|
|
140
|
+
['AltRight', 18],
|
|
141
|
+
['Pause', 19],
|
|
142
|
+
['CapsLock', 20],
|
|
143
|
+
['Escape', 27],
|
|
144
|
+
['Space', 32],
|
|
145
|
+
['PageUp', 33],
|
|
146
|
+
['PageDown', 34],
|
|
147
|
+
['End', 35],
|
|
148
|
+
['Home', 36],
|
|
149
|
+
['ArrowLeft', 37],
|
|
150
|
+
['ArrowUp', 38],
|
|
151
|
+
['ArrowRight', 39],
|
|
152
|
+
['ArrowDown', 40],
|
|
153
|
+
['PrintScreen', 44],
|
|
154
|
+
['Insert', 45],
|
|
155
|
+
['Delete', 46],
|
|
156
|
+
['MetaLeft', 91],
|
|
157
|
+
['MetaRight', 92],
|
|
158
|
+
['ContextMenu', 93],
|
|
159
|
+
['NumpadMultiply', 106],
|
|
160
|
+
['NumpadAdd', 107],
|
|
161
|
+
['NumpadSubtract', 109],
|
|
162
|
+
['NumpadDecimal', 110],
|
|
163
|
+
['NumpadDivide', 111],
|
|
164
|
+
['NumLock', 144],
|
|
165
|
+
['ScrollLock', 145],
|
|
166
|
+
['Semicolon', 186],
|
|
167
|
+
['Equal', 187],
|
|
168
|
+
['Comma', 188],
|
|
169
|
+
['Minus', 189],
|
|
170
|
+
['Period', 190],
|
|
171
|
+
['Slash', 191],
|
|
172
|
+
['Backquote', 192],
|
|
173
|
+
['BracketLeft', 219],
|
|
174
|
+
['Backslash', 220],
|
|
175
|
+
['BracketRight', 221],
|
|
176
|
+
['Quote', 222],
|
|
177
|
+
['IntlBackslash', 226],
|
|
178
|
+
]);
|
|
179
|
+
for (let at = 0; at < 10; at += 1) {
|
|
180
|
+
LEGACY.set(`Digit${at}`, 48 + at);
|
|
181
|
+
LEGACY.set(`Numpad${at}`, 96 + at);
|
|
182
|
+
}
|
|
183
|
+
for (let at = 0; at < 24; at += 1) LEGACY.set(`F${at + 1}`, 112 + at);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `keyCode` for a key down or up. A letter key's is the letter the layout puts there, as Chrome's
|
|
187
|
+
* is — `KeyQ` on an AZERTY board types `a` and reports 65 — and every other key's is its position's.
|
|
188
|
+
*/
|
|
189
|
+
export function legacyKeyCode(code: string, key: string): number {
|
|
190
|
+
if (/^[a-z]$/i.test(key)) return key.toUpperCase().charCodeAt(0);
|
|
191
|
+
if (/^Key[A-Z]$/.test(code)) return code.charCodeAt(3);
|
|
192
|
+
return LEGACY.get(code) ?? 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Which of the four locations a key is in: 1 and 2 for a modifier's two sides, 3 for the keypad.
|
|
197
|
+
* Only the modifiers have sides — `BracketLeft` and `ArrowLeft` are standard keys.
|
|
198
|
+
*/
|
|
199
|
+
export function keyLocation(code: string): number {
|
|
200
|
+
const side = /^(?:Shift|Control|Alt|Meta)(Left|Right)$/.exec(code)?.[1];
|
|
201
|
+
if (side !== undefined) return side === 'Left' ? 1 : 2;
|
|
202
|
+
return code.startsWith('Numpad') ? 3 : 0;
|
|
203
|
+
}
|
package/src/keyEvents.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys, as a page hears them: `keydown`, `keypress` and `keyup`, each a `KeyboardEvent` carrying
|
|
3
|
+
* the `code`, the `key`, the location and the legacy codes Chrome sends.
|
|
4
|
+
*
|
|
5
|
+
* **A key down waits for its character.** SDL reports a key and the text it typed as two events,
|
|
6
|
+
* the key first, and the text is the layout's answer to what was typed: `!` for shift and 1 on a US
|
|
7
|
+
* board, `é` through a dead key. That is what a browser puts in `key`, and what a `keypress`
|
|
8
|
+
* carries. Both arrive in one drain of SDL's queue, so a key down is held until its text, the next
|
|
9
|
+
* event of any other kind, or the end of that drain, whichever is first — never across a frame.
|
|
10
|
+
*
|
|
11
|
+
* **A `keypress` is sent only for a key that typed something**, and for Enter, which SDL types
|
|
12
|
+
* nothing for and Chrome gives a carriage return; a key down a listener prevented sends none, as in
|
|
13
|
+
* Chrome. Ctrl with a letter types nothing in SDL either, which is Chrome's rule too.
|
|
14
|
+
*
|
|
15
|
+
* What it gives up: a key that composes, through an input method, reports what it composed to the
|
|
16
|
+
* text host alone (`textHost.ts`), and its key down names the key SDL saw; and with Ctrl held, a
|
|
17
|
+
* shifted symbol's `key` is the unshifted one, since no text says otherwise.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { codeOf, keyLocation, keyOf, legacyKeyCode } from './keyCodes.ts';
|
|
21
|
+
import { KeyboardEvent, type ModifierInit } from './uiEvents.ts';
|
|
22
|
+
|
|
23
|
+
export interface SdlKey {
|
|
24
|
+
readonly scancode: number;
|
|
25
|
+
readonly key: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Whether this key is an auto-repeat. **A number by SDL's declaration and not always by its
|
|
28
|
+
* behaviour**, so both shapes are accepted and `isRepeat` decides; see its note.
|
|
29
|
+
*/
|
|
30
|
+
readonly repeat?: number | boolean;
|
|
31
|
+
readonly shift: number;
|
|
32
|
+
readonly ctrl: number;
|
|
33
|
+
readonly alt: number;
|
|
34
|
+
readonly super: number;
|
|
35
|
+
readonly altgr?: number;
|
|
36
|
+
readonly capslock?: number;
|
|
37
|
+
readonly numlock?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The modifiers an SDL key event reports, as a browser's event carries them. */
|
|
41
|
+
export function modifiersOf(event: SdlKey): ModifierInit {
|
|
42
|
+
return {
|
|
43
|
+
shiftKey: event.shift !== 0,
|
|
44
|
+
ctrlKey: event.ctrl !== 0,
|
|
45
|
+
altKey: event.alt !== 0,
|
|
46
|
+
metaKey: event.super !== 0,
|
|
47
|
+
modifierAltGraph: (event.altgr ?? 0) !== 0,
|
|
48
|
+
modifierCapsLock: (event.capslock ?? 0) !== 0,
|
|
49
|
+
modifierNumLock: (event.numlock ?? 0) !== 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class KeyEvents {
|
|
54
|
+
private pending: { readonly event: SdlKey; text: string | null } | null = null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `target` is where a key goes: the focused element, or the document with nothing focused.
|
|
58
|
+
* `activate` is told of each key down but Escape, a person's gesture as a browser counts one.
|
|
59
|
+
*/
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly target: () => EventTarget,
|
|
62
|
+
private readonly activate: () => void = () => undefined,
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
down(event: SdlKey): void {
|
|
66
|
+
this.flush();
|
|
67
|
+
this.pending = { event, text: null };
|
|
68
|
+
queueMicrotask(() => this.flush());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A key's character, or a composition's, which is the text host's and not a key's. */
|
|
72
|
+
text(text: string): void {
|
|
73
|
+
const pending = this.pending;
|
|
74
|
+
if (pending === null) return;
|
|
75
|
+
if ([...text].length === 1) pending.text = text;
|
|
76
|
+
this.flush();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
up(event: SdlKey): void {
|
|
80
|
+
this.flush();
|
|
81
|
+
const key = keyOf(event.key, event.scancode, event.shift !== 0);
|
|
82
|
+
this.send('keyup', event, key, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Send the key down being held, and its `keypress` if it typed. */
|
|
86
|
+
flush(): void {
|
|
87
|
+
const pending = this.pending;
|
|
88
|
+
if (pending === null) return;
|
|
89
|
+
this.pending = null;
|
|
90
|
+
const { event, text } = pending;
|
|
91
|
+
const key = text ?? keyOf(event.key, event.scancode, event.shift !== 0);
|
|
92
|
+
if (key !== 'Escape') this.activate();
|
|
93
|
+
const down = this.send('keydown', event, key, 0);
|
|
94
|
+
if (down.defaultPrevented) return;
|
|
95
|
+
const typed = text ?? (key === 'Enter' ? '\r' : null);
|
|
96
|
+
if (typed !== null) this.send('keypress', event, key, typed.codePointAt(0) ?? 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private send(type: string, event: SdlKey, key: string, charCode: number): KeyboardEvent {
|
|
100
|
+
const code = codeOf(event.scancode) ?? '';
|
|
101
|
+
const sent = new KeyboardEvent(type, {
|
|
102
|
+
bubbles: true,
|
|
103
|
+
cancelable: true,
|
|
104
|
+
key,
|
|
105
|
+
code,
|
|
106
|
+
location: keyLocation(code),
|
|
107
|
+
repeat: isRepeat(event.repeat),
|
|
108
|
+
keyCode: charCode !== 0 ? charCode : legacyKeyCode(code, key),
|
|
109
|
+
charCode,
|
|
110
|
+
detail: 0,
|
|
111
|
+
...modifiersOf(event),
|
|
112
|
+
});
|
|
113
|
+
this.target().dispatchEvent(sent);
|
|
114
|
+
return sent;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether the platform is calling this key a repeat, for either shape it might say it in.
|
|
120
|
+
*
|
|
121
|
+
* **`(repeat ?? 0) !== 0` was inverted for a boolean**, and that is the whole of a bug that made
|
|
122
|
+
* the engine deaf to held keys: `false !== 0` is `true`, so a platform answering `repeat: false`
|
|
123
|
+
* marked *every* press a repeat, and `InputSource` discards a repeat outright so that a held key
|
|
124
|
+
* cannot re-fire a press. Taps handled on the edge — a hotbar digit — kept working, which is what
|
|
125
|
+
* made it so hard to see: the keyboard was plainly delivering keys and the player would not walk.
|
|
126
|
+
*
|
|
127
|
+
* SDL's own types declare a number and this took the declaration at its word. **A number is what it
|
|
128
|
+
* promises and not what it was giving**, which is why this reads the value rather than its type:
|
|
129
|
+
* anything positive, or `true`, is a repeat, and everything else — `0`, `false`, absent — is a
|
|
130
|
+
* first press.
|
|
131
|
+
*/
|
|
132
|
+
function isRepeat(repeat: number | boolean | undefined): boolean {
|
|
133
|
+
if (typeof repeat === 'boolean') return repeat;
|
|
134
|
+
return typeof repeat === 'number' && repeat > 0;
|
|
135
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `matchMedia`, answered from what this host knows: a desktop window's pointer, its size and
|
|
3
|
+
* shape, its pixel ratio, and whether it is fullscreen.
|
|
4
|
+
*
|
|
5
|
+
* **The engine asks `(pointer: coarse)`** to choose touch controls and to ask for fullscreen on a
|
|
6
|
+
* first gesture, and a window with no `matchMedia` at all read as a desktop only because the engine
|
|
7
|
+
* guards the call. A consumer may ask anything, so the common features are answered:
|
|
8
|
+
* - `pointer` and `hover` as a desktop's mouse, `fine` and `hover`. `any-pointer` adds `coarse`
|
|
9
|
+
* once a finger has landed, because SDL cannot say whether a touch screen exists — on the machine
|
|
10
|
+
* this was written on it listed X11's master pointer, "Virtual core pointer", as one.
|
|
11
|
+
* - `width`, `height` and `orientation` from the window, in either spelling of a range;
|
|
12
|
+
* `resolution` from its pixel ratio.
|
|
13
|
+
* - `display-mode` as `fullscreen` or `standalone`, the display mode of an app's own window.
|
|
14
|
+
* - Preferences as a person who set none: `prefers-reduced-motion: no-preference`,
|
|
15
|
+
* `prefers-color-scheme: light`, `prefers-contrast: no-preference`, `forced-colors: none`.
|
|
16
|
+
*
|
|
17
|
+
* A list is live, as a browser's is, and is sent `change` when its answer flips. **A feature this
|
|
18
|
+
* does not know answers false and is named once on the console**, rather than a guess at what a
|
|
19
|
+
* browser would have said.
|
|
20
|
+
*
|
|
21
|
+
* What it gives up: a desktop's colour scheme is not read, so a dark desktop still answers
|
|
22
|
+
* `light`; and a machine with only a touch screen answers as one with a mouse until told
|
|
23
|
+
* otherwise.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { HostNode } from './domTree.ts';
|
|
27
|
+
|
|
28
|
+
export interface MediaFacts {
|
|
29
|
+
/** The viewport, in CSS pixels. */
|
|
30
|
+
readonly width: number;
|
|
31
|
+
readonly height: number;
|
|
32
|
+
readonly pixelRatio: number;
|
|
33
|
+
readonly fullscreen: boolean;
|
|
34
|
+
/** Whether a finger has landed, which is the one proof of a touch screen SDL gives. */
|
|
35
|
+
readonly touched: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Feature = (value: string | null, facts: MediaFacts) => boolean;
|
|
39
|
+
|
|
40
|
+
const is = (answer: string) => (value: string | null) => value === null || value === answer;
|
|
41
|
+
const px = (value: string) => Number.parseFloat(value);
|
|
42
|
+
const dppx = (value: string) =>
|
|
43
|
+
value.endsWith('dpi') ? Number.parseFloat(value) / 96 : Number.parseFloat(value);
|
|
44
|
+
|
|
45
|
+
const FEATURES: Record<string, Feature> = {
|
|
46
|
+
pointer: is('fine'),
|
|
47
|
+
'any-pointer': (v, f) => v === null || v === 'fine' || (v === 'coarse' && f.touched),
|
|
48
|
+
hover: is('hover'),
|
|
49
|
+
'any-hover': is('hover'),
|
|
50
|
+
orientation: (v, f) => v === (f.height >= f.width ? 'portrait' : 'landscape'),
|
|
51
|
+
width: (v, f) => v === null || f.width === px(v),
|
|
52
|
+
'min-width': (v, f) => v !== null && f.width >= px(v),
|
|
53
|
+
'max-width': (v, f) => v !== null && f.width <= px(v),
|
|
54
|
+
height: (v, f) => v === null || f.height === px(v),
|
|
55
|
+
'min-height': (v, f) => v !== null && f.height >= px(v),
|
|
56
|
+
'max-height': (v, f) => v !== null && f.height <= px(v),
|
|
57
|
+
'min-resolution': (v, f) => v !== null && f.pixelRatio >= dppx(v),
|
|
58
|
+
'max-resolution': (v, f) => v !== null && f.pixelRatio <= dppx(v),
|
|
59
|
+
'display-mode': (v, f) => v === (f.fullscreen ? 'fullscreen' : 'standalone'),
|
|
60
|
+
'prefers-reduced-motion': (v) => v === 'no-preference',
|
|
61
|
+
'prefers-color-scheme': (v) => v === 'light',
|
|
62
|
+
'prefers-contrast': (v) => v === 'no-preference',
|
|
63
|
+
'forced-colors': (v) => v === 'none',
|
|
64
|
+
color: () => true,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** `(width >= 600px)` and its kin, the range spelling, as the equivalent `min-` or `max-`. */
|
|
68
|
+
function range(condition: string): [string, string] | null {
|
|
69
|
+
const match = /^(width|height)\s*(>=|<=|>|<)\s*(\S+)$/.exec(condition);
|
|
70
|
+
if (match === null) return null;
|
|
71
|
+
const [, name, op, value] = match as unknown as [string, string, string, string];
|
|
72
|
+
const nudge = op === '>' ? 0.001 : op === '<' ? -0.001 : 0;
|
|
73
|
+
const bound = `${px(value) + nudge}px`;
|
|
74
|
+
return [op.startsWith('>') ? `min-${name}` : `max-${name}`, bound];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `pointer: coarse` as its name and value; a bare `color` has no value. */
|
|
78
|
+
function nameAndValue(condition: string): [string, string | null] {
|
|
79
|
+
const colon = condition.indexOf(':');
|
|
80
|
+
if (colon < 0) return [condition, null];
|
|
81
|
+
return [condition.slice(0, colon).trim(), condition.slice(colon + 1).trim()];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class MediaQueryList extends HostNode {
|
|
85
|
+
private last: boolean;
|
|
86
|
+
constructor(
|
|
87
|
+
readonly media: string,
|
|
88
|
+
private readonly answer: () => boolean,
|
|
89
|
+
) {
|
|
90
|
+
super();
|
|
91
|
+
this.last = answer();
|
|
92
|
+
}
|
|
93
|
+
get matches(): boolean {
|
|
94
|
+
return this.answer();
|
|
95
|
+
}
|
|
96
|
+
/** The legacy pair, still read. */
|
|
97
|
+
addListener(listener: (event: Event) => void): void {
|
|
98
|
+
this.addEventListener('change', listener);
|
|
99
|
+
}
|
|
100
|
+
removeListener(listener: (event: Event) => void): void {
|
|
101
|
+
this.removeEventListener('change', listener);
|
|
102
|
+
}
|
|
103
|
+
/** Tell the list's listeners if its answer has flipped since they were last told. */
|
|
104
|
+
recheck(): void {
|
|
105
|
+
const now = this.answer();
|
|
106
|
+
if (now === this.last) return;
|
|
107
|
+
this.last = now;
|
|
108
|
+
this.dispatchEvent(Object.assign(new Event('change'), { media: this.media, matches: now }));
|
|
109
|
+
}
|
|
110
|
+
static {
|
|
111
|
+
HostNode.handles(MediaQueryList.prototype, ['change']);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface MediaQueryList {
|
|
116
|
+
onchange: ((event: Event) => unknown) | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class MediaQueries {
|
|
120
|
+
private readonly lists: MediaQueryList[] = [];
|
|
121
|
+
private readonly unknown = new Set<string>();
|
|
122
|
+
|
|
123
|
+
constructor(private readonly facts: () => MediaFacts) {}
|
|
124
|
+
|
|
125
|
+
match(query: string): MediaQueryList {
|
|
126
|
+
const list = new MediaQueryList(query.trim(), () => this.evaluate(query));
|
|
127
|
+
this.lists.push(list);
|
|
128
|
+
return list;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Something a query can read has changed: each list whose answer flipped is told. */
|
|
132
|
+
changed(): void {
|
|
133
|
+
for (const list of this.lists) list.recheck();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private evaluate(query: string): boolean {
|
|
137
|
+
const facts = this.facts();
|
|
138
|
+
return query.split(',').some((one) => this.single(one.trim().toLowerCase(), facts));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One query of a list: `not` or `only`, a media type, and conditions joined by `and`. */
|
|
142
|
+
private single(query: string, facts: MediaFacts): boolean {
|
|
143
|
+
let rest = query;
|
|
144
|
+
const negated = rest.startsWith('not ');
|
|
145
|
+
if (negated || rest.startsWith('only ')) rest = rest.slice(rest.indexOf(' ') + 1).trim();
|
|
146
|
+
const parts = rest.split(/\s+and\s+/);
|
|
147
|
+
let result = true;
|
|
148
|
+
for (const part of parts) {
|
|
149
|
+
if (part === 'all' || part === 'screen') continue;
|
|
150
|
+
if (!part.startsWith('(') || !part.endsWith(')')) {
|
|
151
|
+
result = false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!this.condition(part.slice(1, -1).trim(), facts)) result = false;
|
|
155
|
+
}
|
|
156
|
+
return negated ? !result : result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private condition(condition: string, facts: MediaFacts): boolean {
|
|
160
|
+
const [name, value] = range(condition) ?? nameAndValue(condition);
|
|
161
|
+
const feature = FEATURES[name];
|
|
162
|
+
if (feature === undefined) {
|
|
163
|
+
if (!this.unknown.has(name)) {
|
|
164
|
+
this.unknown.add(name);
|
|
165
|
+
console.warn(
|
|
166
|
+
`[driftengine] the native host does not answer the media feature \`${name}\`; it reads as false`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return feature(value, facts);
|
|
172
|
+
}
|
|
173
|
+
}
|