@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/device.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device, made to say what it rejected, when its binding will not.
|
|
3
|
+
*
|
|
4
|
+
* **The engine hears one thing from its device**: `uncapturederror`, which its surface turns into a
|
|
5
|
+
* line naming what the GPU refused — the only warning a WebGPU frame gets that it drew nothing.
|
|
6
|
+
* `@kmamal/gpu` 0.2.0 refuses that listener ("no overload matched for addEventListener") and will not
|
|
7
|
+
* take an `onuncapturederror` either (measured 2026-09-18). Error scopes work, so the host opens one
|
|
8
|
+
* around each frame and hands whatever it caught to the listeners the engine registered: the same
|
|
9
|
+
* message, at the end of the frame that caused it, which is when a browser raises it too.
|
|
10
|
+
*
|
|
11
|
+
* **Detected, not assumed.** A device whose binding takes the listener is left exactly as it is, so a
|
|
12
|
+
* later binding that delivers its own events costs nothing here.
|
|
13
|
+
*
|
|
14
|
+
* What it gives up: an error from work outside a frame — a pipeline built at mount — is only caught
|
|
15
|
+
* if the host has a scope open at the time. Dawn still prints every validation error to the
|
|
16
|
+
* terminal itself, so none is silent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AlphaCopier } from './alphaCopy.ts';
|
|
20
|
+
import type { AlphaStep } from './alphaCopy.ts';
|
|
21
|
+
import { isHostBitmap } from './images.ts';
|
|
22
|
+
|
|
23
|
+
export interface FrameErrors {
|
|
24
|
+
/** Before the frame: catch what it does. */
|
|
25
|
+
open(): void;
|
|
26
|
+
/** After it: hand anything caught to the engine's listeners. */
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const adapted = new WeakMap<GPUDevice, FrameErrors>();
|
|
31
|
+
|
|
32
|
+
/** The frame's error scopes for a device this host adapted, or `null` for one it left alone. */
|
|
33
|
+
export function errorsOf(device: GPUDevice): FrameErrors | null {
|
|
34
|
+
return adapted.get(device) ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Skia's premultiply, the integer rounding Chrome's image decoders use. */
|
|
38
|
+
export function premultiplied(straight: Uint8Array): Uint8Array {
|
|
39
|
+
const out = new Uint8Array(straight.length);
|
|
40
|
+
for (let at = 0; at < straight.length; at += 4) {
|
|
41
|
+
const alpha = straight[at + 3] as number;
|
|
42
|
+
out[at + 3] = alpha;
|
|
43
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
44
|
+
out[at + channel] = (((straight[at + channel] as number) * alpha + 128) * 257) >> 16;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The step a copy takes between the alpha an image is held in and the alpha its texture wants, or
|
|
52
|
+
* null where the two agree and a browser copies the bytes as they are.
|
|
53
|
+
*/
|
|
54
|
+
export function alphaStepFor(held: boolean, wanted: boolean): AlphaStep | null {
|
|
55
|
+
if (held === wanted) return null;
|
|
56
|
+
return held ? 'unpremultiply' : 'premultiply';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The queue, taking an image the host decoded where a browser would take one it decoded.
|
|
61
|
+
*
|
|
62
|
+
* **The engine puts a decoded image on the device with `copyExternalImageToTexture`**, and a browser
|
|
63
|
+
* copies from its own image there. A host's image is a `HostBitmap` from `images.ts`, four bytes a
|
|
64
|
+
* pixel that Dawn's binding cannot read as an image, so it is copied the way Chrome copies one:
|
|
65
|
+
*
|
|
66
|
+
* - **held as the texture wants it**, the bytes go in with `writeTexture`. Straight or
|
|
67
|
+
* premultiplied, into a plain or an sRGB texture, Chrome writes exactly these (measured);
|
|
68
|
+
* - **held one way and wanted the other**, `alphaCopy.ts` runs the step on the device.
|
|
69
|
+
*
|
|
70
|
+
* A browser holds a decoded image premultiplied unless told `premultiplyAlpha: 'none'`, with Skia's
|
|
71
|
+
* integer rounding, so that is how the bytes are held here. A browser flips rows only when asked,
|
|
72
|
+
* and so does this. Any other source goes to the binding.
|
|
73
|
+
*/
|
|
74
|
+
function adaptQueue(device: GPUDevice): void {
|
|
75
|
+
const queue = device.queue as GPUQueue | undefined;
|
|
76
|
+
if (queue === undefined) return;
|
|
77
|
+
const original = queue.copyExternalImageToTexture.bind(queue);
|
|
78
|
+
const copier = new AlphaCopier(device);
|
|
79
|
+
queue.copyExternalImageToTexture = (source, destination, copySize) => {
|
|
80
|
+
/* A size may be any iterable of numbers or a dictionary; one shape, for both uses below. */
|
|
81
|
+
const size: GPUExtent3D =
|
|
82
|
+
Symbol.iterator in Object(copySize)
|
|
83
|
+
? Array.from(copySize as Iterable<number>)
|
|
84
|
+
: (copySize as GPUExtent3DDict);
|
|
85
|
+
const image: unknown = source.source;
|
|
86
|
+
if (!isHostBitmap(image)) {
|
|
87
|
+
original(source, destination, size);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const from = source.origin as GPUOrigin2DDict | undefined;
|
|
91
|
+
if ((from?.x ?? 0) !== 0 || (from?.y ?? 0) !== 0) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'[driftengine] the native host copies a whole image, from its top-left corner',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const row = image.width * 4;
|
|
97
|
+
const straight = new Uint8Array(
|
|
98
|
+
image.data.buffer,
|
|
99
|
+
image.data.byteOffset,
|
|
100
|
+
image.data.byteLength,
|
|
101
|
+
);
|
|
102
|
+
let bytes = image.premultiplied ? premultiplied(straight) : straight;
|
|
103
|
+
if (source.flipY === true) {
|
|
104
|
+
const flipped = new Uint8Array(bytes.length);
|
|
105
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
106
|
+
flipped.set(bytes.subarray(y * row, (y + 1) * row), (image.height - 1 - y) * row);
|
|
107
|
+
}
|
|
108
|
+
bytes = flipped;
|
|
109
|
+
}
|
|
110
|
+
const width = Array.isArray(size) ? (size[0] as number) : (size as GPUExtent3DDict).width;
|
|
111
|
+
const height = Array.isArray(size) ? (size[1] ?? 1) : ((size as GPUExtent3DDict).height ?? 1);
|
|
112
|
+
const step = alphaStepFor(image.premultiplied, destination.premultipliedAlpha === true);
|
|
113
|
+
if (step !== null) {
|
|
114
|
+
copier.copy(bytes, image.width, image.height, step, destination, width, height);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
queue.writeTexture(
|
|
118
|
+
{
|
|
119
|
+
texture: destination.texture,
|
|
120
|
+
mipLevel: destination.mipLevel ?? 0,
|
|
121
|
+
origin: destination.origin ?? { x: 0, y: 0, z: 0 },
|
|
122
|
+
},
|
|
123
|
+
bytes,
|
|
124
|
+
{ offset: 0, bytesPerRow: row, rowsPerImage: image.height },
|
|
125
|
+
{ width, height, depthOrArrayLayers: 1 },
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function adaptDevice(device: GPUDevice): GPUDevice {
|
|
131
|
+
adaptQueue(device);
|
|
132
|
+
const original = device.addEventListener.bind(device);
|
|
133
|
+
try {
|
|
134
|
+
original('uncapturederror', () => undefined);
|
|
135
|
+
return device;
|
|
136
|
+
} catch {
|
|
137
|
+
/* The binding refused it: this device needs the adapter. */
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const listeners = new Set<EventListenerOrEventListenerObject>();
|
|
141
|
+
const deliver = (error: GPUError): void => {
|
|
142
|
+
const event = { type: 'uncapturederror', error } as unknown as GPUUncapturedErrorEvent;
|
|
143
|
+
for (const listener of listeners) {
|
|
144
|
+
if (typeof listener === 'function') listener(event);
|
|
145
|
+
else listener.handleEvent(event);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const scoped = device as unknown as {
|
|
149
|
+
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
|
|
150
|
+
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
|
|
151
|
+
};
|
|
152
|
+
scoped.addEventListener = (type, listener) => {
|
|
153
|
+
if (type === 'uncapturederror') listeners.add(listener);
|
|
154
|
+
};
|
|
155
|
+
scoped.removeEventListener = (type, listener) => {
|
|
156
|
+
if (type === 'uncapturederror') listeners.delete(listener);
|
|
157
|
+
};
|
|
158
|
+
adapted.set(device, {
|
|
159
|
+
open: () => device.pushErrorScope('validation'),
|
|
160
|
+
close: () => {
|
|
161
|
+
void device.popErrorScope().then((error) => {
|
|
162
|
+
if (error !== null) deliver(error);
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return device;
|
|
167
|
+
}
|
package/src/dialogs.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open and save dialogs from the desktop, since SDL 2 gives a window none.
|
|
3
|
+
*
|
|
4
|
+
* **The desktop's own dialog program, asked and waited for**: zenity, which GNOME and the GTK
|
|
5
|
+
* desktops carry, and kdialog where zenity is not installed. What comes back is what the desktop
|
|
6
|
+
* shell hands a game (`main/ipc.ts`): a picked file's name and bytes, `null` for a cancelled pick,
|
|
7
|
+
* and `false` for a cancelled save — a cancellation being exit code 1 for both programs.
|
|
8
|
+
*
|
|
9
|
+
* **A filter is the extensions the game named**, `.json` becoming `*.json`; a MIME type has no
|
|
10
|
+
* pattern either program takes, so it is left out rather than guessed at.
|
|
11
|
+
*
|
|
12
|
+
* What it gives up: Windows and macOS, which carry neither program and are Task 7's; the desktop
|
|
13
|
+
* portal, which is what a sandboxed Linux build would need and answers over D-Bus signals rather
|
|
14
|
+
* than an exit code; and where neither program is installed, `desktopFiles` answers null and the
|
|
15
|
+
* bridge reports every pick cancelled.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { accessSync, constants } from 'node:fs';
|
|
20
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { basename, delimiter, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import type { NativeFiles } from './bridge.ts';
|
|
24
|
+
|
|
25
|
+
/** Run a dialog program and hand back its exit code and what it printed. */
|
|
26
|
+
export type DialogRunner = (
|
|
27
|
+
command: string,
|
|
28
|
+
args: readonly string[],
|
|
29
|
+
) => Promise<{ code: number; stdout: string }>;
|
|
30
|
+
|
|
31
|
+
const spawnRunner: DialogRunner = (command, args) =>
|
|
32
|
+
new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
34
|
+
let stdout = '';
|
|
35
|
+
child.stdout.setEncoding('utf8');
|
|
36
|
+
child.stdout.on('data', (chunk: string) => {
|
|
37
|
+
stdout += chunk;
|
|
38
|
+
});
|
|
39
|
+
child.once('error', reject);
|
|
40
|
+
child.once('close', (code) => resolve({ code: code ?? 1, stdout }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Whether `name` is an executable on the search path. */
|
|
44
|
+
function onPath(name: string): boolean {
|
|
45
|
+
for (const directory of (process.env['PATH'] ?? '').split(delimiter)) {
|
|
46
|
+
try {
|
|
47
|
+
accessSync(join(directory, name), constants.X_OK);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
/* Not in this one. */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const patternsOf = (accept: readonly string[]) =>
|
|
57
|
+
accept.filter((entry) => entry.startsWith('.')).map((extension) => `*${extension}`);
|
|
58
|
+
|
|
59
|
+
/** The chosen path, or null when the person cancelled; anything else is a fault and says so. */
|
|
60
|
+
function chosen(command: string, answer: { code: number; stdout: string }): string | null {
|
|
61
|
+
if (answer.code === 0) return answer.stdout.replace(/\r?\n$/, '');
|
|
62
|
+
if (answer.code !== 1) console.warn(`[driftengine] ${command} ended with code ${answer.code}`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function desktopFiles(
|
|
67
|
+
available: (name: string) => boolean = onPath,
|
|
68
|
+
run: DialogRunner = spawnRunner,
|
|
69
|
+
): NativeFiles | null {
|
|
70
|
+
const command = available('zenity') ? 'zenity' : available('kdialog') ? 'kdialog' : null;
|
|
71
|
+
if (command === null) return null;
|
|
72
|
+
const zenity = command === 'zenity';
|
|
73
|
+
return {
|
|
74
|
+
async openFile(accept) {
|
|
75
|
+
const patterns = patternsOf(accept);
|
|
76
|
+
const args = zenity
|
|
77
|
+
? [
|
|
78
|
+
'--file-selection',
|
|
79
|
+
...(patterns.length > 0
|
|
80
|
+
? [`--file-filter=${patterns.join(' ')} | ${patterns.join(' ')}`]
|
|
81
|
+
: []),
|
|
82
|
+
]
|
|
83
|
+
: ['--getopenfilename', '.', ...(patterns.length > 0 ? [patterns.join(' ')] : [])];
|
|
84
|
+
const path = chosen(command, await run(command, args));
|
|
85
|
+
if (path === null) return null;
|
|
86
|
+
return { name: basename(path), bytes: new Uint8Array(await readFile(path)) };
|
|
87
|
+
},
|
|
88
|
+
async saveFile(name, bytes) {
|
|
89
|
+
const args = zenity
|
|
90
|
+
? ['--file-selection', '--save', `--filename=${name}`]
|
|
91
|
+
: ['--getsavefilename', name];
|
|
92
|
+
const path = chosen(command, await run(command, args));
|
|
93
|
+
if (path === null) return false;
|
|
94
|
+
await writeFile(path, bytes);
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/domEvents.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What SDL tells a window, as the events a page hears: keys, the mouse, the wheel, focus,
|
|
3
|
+
* visibility, resizing and closing.
|
|
4
|
+
*
|
|
5
|
+
* **The engine's input is not changed for this host.** `InputSource` listens on `window`,
|
|
6
|
+
* `document` and its canvas for the events a browser sends, and so do the scenes, so they are sent
|
|
7
|
+
* here in that shape: each the browser's class (`uiEvents.ts`), a key by its `code` and with the
|
|
8
|
+
* character the layout typed (`keyEvents.ts`), a button in the browser's numbering, the wheel in
|
|
9
|
+
* the browser's direction. Each is dispatched at the canvas, or at what has the focus, and the tree
|
|
10
|
+
* (`domTree.ts`) captures and bubbles it as a browser's does.
|
|
11
|
+
*
|
|
12
|
+
* The mouse is `mouseEvents.ts`, which says what Chrome sends for it; keys are `keyEvents.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Fingers are `touchEvents.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { NativeCanvas } from './canvas.ts';
|
|
18
|
+
import { KeyEvents, type SdlKey } from './keyEvents.ts';
|
|
19
|
+
import { type EventMouse, MouseModel, type SdlPointer } from './mouseEvents.ts';
|
|
20
|
+
import { type SdlFinger, TouchModel } from './touchEvents.ts';
|
|
21
|
+
import type { HostPage } from './page.ts';
|
|
22
|
+
|
|
23
|
+
export type { EventMouse } from './mouseEvents.ts';
|
|
24
|
+
|
|
25
|
+
/** What this reads of an SDL window: its events, where it is on the screen, and its size. */
|
|
26
|
+
export interface EventWindow {
|
|
27
|
+
on(type: string, listener: (event: never) => void): unknown;
|
|
28
|
+
off(type: string, listener: (event: never) => void): unknown;
|
|
29
|
+
readonly x: number;
|
|
30
|
+
readonly y: number;
|
|
31
|
+
readonly width: number;
|
|
32
|
+
readonly height: number;
|
|
33
|
+
/** Whether SDL has the window fullscreen, and the way to ask; a window without them has none. */
|
|
34
|
+
readonly fullscreen?: boolean;
|
|
35
|
+
setFullscreen?(on: boolean): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EventOptions {
|
|
39
|
+
/** The clock clicks are counted against, in milliseconds. */
|
|
40
|
+
readonly now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ESCAPE = 41;
|
|
44
|
+
/** The modifier keys by scancode, both sides of each, as the bits `modifiers` keeps. */
|
|
45
|
+
const MODIFIER = new Map([
|
|
46
|
+
[224, 1],
|
|
47
|
+
[228, 1],
|
|
48
|
+
[225, 2],
|
|
49
|
+
[229, 2],
|
|
50
|
+
[226, 4],
|
|
51
|
+
[230, 4],
|
|
52
|
+
[227, 8],
|
|
53
|
+
[231, 8],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Connect `window`'s events to `page` and `canvas`, and hand back what disconnects them. */
|
|
57
|
+
export function connectDomEvents(
|
|
58
|
+
window: EventWindow,
|
|
59
|
+
mouse: EventMouse,
|
|
60
|
+
page: HostPage,
|
|
61
|
+
canvas: NativeCanvas,
|
|
62
|
+
options: EventOptions = {},
|
|
63
|
+
): () => void {
|
|
64
|
+
const keys = new KeyEvents(
|
|
65
|
+
() => page.keyTarget(),
|
|
66
|
+
() => page.userActivation.notify(),
|
|
67
|
+
);
|
|
68
|
+
/*
|
|
69
|
+
* **Leaving puts the window back as it was**, not into a window: one already fullscreen before the
|
|
70
|
+
* page asked — a game packaged fullscreen, or put there by the shell's bridge — stays so, as a
|
|
71
|
+
* browser under F11 stays fullscreen when a page's element leaves.
|
|
72
|
+
*/
|
|
73
|
+
let before = false;
|
|
74
|
+
page.setFullscreener((on) => {
|
|
75
|
+
if (window.setFullscreen === undefined) return false;
|
|
76
|
+
try {
|
|
77
|
+
if (on) before = window.fullscreen === true;
|
|
78
|
+
window.setFullscreen(on || before);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
/* Which modifiers are down, so a pointer event can say, as a browser's does. */
|
|
85
|
+
let modifiers = 0;
|
|
86
|
+
const held = () => ({
|
|
87
|
+
ctrlKey: (modifiers & 1) !== 0,
|
|
88
|
+
shiftKey: (modifiers & 2) !== 0,
|
|
89
|
+
altKey: (modifiers & 4) !== 0,
|
|
90
|
+
metaKey: (modifiers & 8) !== 0,
|
|
91
|
+
});
|
|
92
|
+
const now = options.now ?? (() => performance.now());
|
|
93
|
+
const pointer = new MouseModel(window, mouse, page, canvas, held, now);
|
|
94
|
+
const fingers = new TouchModel(window, page, canvas, held, now);
|
|
95
|
+
canvas.cursorer = (css) => pointer.cursor(css);
|
|
96
|
+
/* SDL makes a mouse of every finger, which the finger's own events have already said. */
|
|
97
|
+
const mouseOnly = (handle: (event: SdlPointer) => void) => (event: SdlPointer) => {
|
|
98
|
+
if (event.touch !== true) handle(event);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const listeners: [string, (event: never) => void][] = [
|
|
102
|
+
[
|
|
103
|
+
'keyDown',
|
|
104
|
+
(event: SdlKey) => {
|
|
105
|
+
/* Escape is the browser's before it is the page's: it lets go of the lock and fullscreen. */
|
|
106
|
+
if (event.scancode === ESCAPE && pointer.isLocked) page.unlock();
|
|
107
|
+
if (event.scancode === ESCAPE) void page.document.exitFullscreen().catch(() => undefined);
|
|
108
|
+
modifiers |= MODIFIER.get(event.scancode) ?? 0;
|
|
109
|
+
keys.down(event);
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
['textInput', ({ text }: { text: string }) => keys.text(text)],
|
|
113
|
+
[
|
|
114
|
+
'keyUp',
|
|
115
|
+
(event: SdlKey) => {
|
|
116
|
+
modifiers &= ~(MODIFIER.get(event.scancode) ?? 0);
|
|
117
|
+
keys.up(event);
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
['mouseMove', mouseOnly((event) => pointer.move(event))],
|
|
121
|
+
['mouseButtonDown', mouseOnly((event) => pointer.down(event))],
|
|
122
|
+
['mouseButtonUp', mouseOnly((event) => pointer.up(event))],
|
|
123
|
+
['mouseWheel', mouseOnly((event) => pointer.wheel(event))],
|
|
124
|
+
['fingerDown', (event: SdlFinger) => fingers.down(event)],
|
|
125
|
+
['fingerMove', (event: SdlFinger) => fingers.move(event)],
|
|
126
|
+
['fingerUp', (event: SdlFinger) => fingers.up(event)],
|
|
127
|
+
['hover', () => pointer.hover()],
|
|
128
|
+
['leave', () => pointer.leave()],
|
|
129
|
+
['focus', () => page.setFocused(true)],
|
|
130
|
+
[
|
|
131
|
+
'blur',
|
|
132
|
+
() => {
|
|
133
|
+
page.unlock();
|
|
134
|
+
/* A key released in another window is never heard here. */
|
|
135
|
+
modifiers = 0;
|
|
136
|
+
page.setFocused(false);
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
['minimize', () => page.setVisible(false)],
|
|
140
|
+
['hide', () => page.setVisible(false)],
|
|
141
|
+
['restore', () => page.setVisible(true)],
|
|
142
|
+
['show', () => page.setVisible(true)],
|
|
143
|
+
['maximize', () => page.setVisible(true)],
|
|
144
|
+
/* After the host's own listener has resized the canvas, which it registered first. */
|
|
145
|
+
[
|
|
146
|
+
'resize',
|
|
147
|
+
() => {
|
|
148
|
+
/* Taken out of fullscreen by something other than the page: the element leaves with it. */
|
|
149
|
+
if (window.fullscreen === false) page.fullscreen.lost();
|
|
150
|
+
page.window.dispatchEvent(new Event('resize'));
|
|
151
|
+
/* A query about the window's size or shape may now answer otherwise. */
|
|
152
|
+
page.media.changed();
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
['beforeClose', () => page.window.dispatchEvent(new Event('beforeunload'))],
|
|
156
|
+
];
|
|
157
|
+
/* A key held for its text is sent before anything that happened after it. */
|
|
158
|
+
for (const entry of listeners) {
|
|
159
|
+
const [type, listener] = entry;
|
|
160
|
+
if (type === 'keyDown' || type === 'textInput') continue;
|
|
161
|
+
entry[1] = (event: never) => {
|
|
162
|
+
keys.flush();
|
|
163
|
+
listener(event);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
for (const [type, listener] of listeners) window.on(type, listener);
|
|
167
|
+
return () => {
|
|
168
|
+
for (const [type, listener] of listeners) window.off(type, listener);
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/domTree.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three nodes a host's page has — window, document and canvas, each the parent of the next —
|
|
3
|
+
* and a browser's dispatch across them: capture from the window down, the target, then bubbling
|
|
4
|
+
* back up.
|
|
5
|
+
*
|
|
6
|
+
* **Node's `EventTarget` has no tree**, so an event sent to each node in turn reaches a window's
|
|
7
|
+
* capture listener last rather than first, tells a listener on the window that the window was its
|
|
8
|
+
* target, and lets a passive listener cancel it. Each of those is a page behaving differently here
|
|
9
|
+
* than in Chrome — the engine's own `requestFullscreenOnGesture` listens on the window in the
|
|
10
|
+
* capture phase — so dispatch is the DOM's, for the one shape of tree this host has.
|
|
11
|
+
*
|
|
12
|
+
* **Event handler attributes are listeners**, as the HTML specification makes them: `onclick`
|
|
13
|
+
* assigned is added to the list at that moment, keeps its place when reassigned, and a handler
|
|
14
|
+
* returning `false` cancels the event. A page reached for them — `canvas.ondblclick` — and on a
|
|
15
|
+
* bare `EventTarget` an assignment is a property nothing reads.
|
|
16
|
+
*
|
|
17
|
+
* What it gives up: no shadow roots, no `composed`, and no retargeting, none of which a canvas
|
|
18
|
+
* with nothing inside it can need.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The `on…` attributes for `Types`, as a class declares them for the checker. */
|
|
22
|
+
export type Handlers<Types extends string> = {
|
|
23
|
+
[Type in Types as `on${Type}`]: ((event: Event) => unknown) | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface Listener {
|
|
27
|
+
readonly callback: EventListenerOrEventListenerObject;
|
|
28
|
+
readonly capture: boolean;
|
|
29
|
+
readonly once: boolean;
|
|
30
|
+
readonly passive: boolean;
|
|
31
|
+
removed: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CAPTURING = 1;
|
|
35
|
+
const AT_TARGET = 2;
|
|
36
|
+
const BUBBLING = 3;
|
|
37
|
+
|
|
38
|
+
/** Set on an event while it is dispatched here, read back by the accessors defined on it. */
|
|
39
|
+
interface Dispatch {
|
|
40
|
+
target: HostNode;
|
|
41
|
+
current: HostNode | null;
|
|
42
|
+
phase: number;
|
|
43
|
+
path: HostNode[];
|
|
44
|
+
stopped: boolean;
|
|
45
|
+
stoppedNow: boolean;
|
|
46
|
+
}
|
|
47
|
+
const dispatches = new WeakMap<Event, Dispatch>();
|
|
48
|
+
|
|
49
|
+
function optionsOf(options: boolean | AddEventListenerOptions | undefined) {
|
|
50
|
+
if (typeof options === 'boolean') return { capture: options, once: false, passive: false };
|
|
51
|
+
return {
|
|
52
|
+
capture: options?.capture === true,
|
|
53
|
+
once: options?.once === true,
|
|
54
|
+
passive: options?.passive === true,
|
|
55
|
+
signal: options?.signal,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Give `event` the accessors a dispatch answers, once; Node's own are read-only slots. */
|
|
60
|
+
function adopt(event: Event, dispatch: Dispatch): void {
|
|
61
|
+
const known = dispatches.has(event);
|
|
62
|
+
dispatches.set(event, dispatch);
|
|
63
|
+
if (known) return;
|
|
64
|
+
const own = (name: string, get: () => unknown) =>
|
|
65
|
+
Object.defineProperty(event, name, { get, configurable: true });
|
|
66
|
+
const now = () => dispatches.get(event) as Dispatch;
|
|
67
|
+
own('target', () => now().target);
|
|
68
|
+
own('srcElement', () => now().target);
|
|
69
|
+
own('currentTarget', () => now().current);
|
|
70
|
+
own('eventPhase', () => now().phase);
|
|
71
|
+
Object.defineProperty(event, 'composedPath', {
|
|
72
|
+
value: () => (now().current === null ? [] : [...now().path]),
|
|
73
|
+
configurable: true,
|
|
74
|
+
});
|
|
75
|
+
const stop = event.stopPropagation.bind(event);
|
|
76
|
+
const stopNow = event.stopImmediatePropagation.bind(event);
|
|
77
|
+
Object.defineProperty(event, 'stopPropagation', {
|
|
78
|
+
value: () => {
|
|
79
|
+
now().stopped = true;
|
|
80
|
+
stop();
|
|
81
|
+
},
|
|
82
|
+
configurable: true,
|
|
83
|
+
});
|
|
84
|
+
Object.defineProperty(event, 'stopImmediatePropagation', {
|
|
85
|
+
value: () => {
|
|
86
|
+
now().stopped = true;
|
|
87
|
+
now().stoppedNow = true;
|
|
88
|
+
stopNow();
|
|
89
|
+
},
|
|
90
|
+
configurable: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class HostNode implements EventTarget {
|
|
95
|
+
parentNode: HostNode | null = null;
|
|
96
|
+
private readonly listeners = new Map<string, Listener[]>();
|
|
97
|
+
private readonly handlers = new Map<string, { value: unknown; listener: EventListener }>();
|
|
98
|
+
|
|
99
|
+
addEventListener(
|
|
100
|
+
type: string,
|
|
101
|
+
callback: EventListenerOrEventListenerObject | null,
|
|
102
|
+
options?: boolean | AddEventListenerOptions,
|
|
103
|
+
): void {
|
|
104
|
+
if (callback === null) return;
|
|
105
|
+
const { capture, once, passive, signal } = optionsOf(options);
|
|
106
|
+
if (signal?.aborted === true) return;
|
|
107
|
+
const list = this.listeners.get(type) ?? [];
|
|
108
|
+
if (list.some((l) => l.callback === callback && l.capture === capture)) return;
|
|
109
|
+
list.push({ callback, capture, once, passive, removed: false });
|
|
110
|
+
this.listeners.set(type, list);
|
|
111
|
+
signal?.addEventListener('abort', () => this.removeEventListener(type, callback, capture));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
removeEventListener(
|
|
115
|
+
type: string,
|
|
116
|
+
callback: EventListenerOrEventListenerObject | null,
|
|
117
|
+
options?: boolean | EventListenerOptions,
|
|
118
|
+
): void {
|
|
119
|
+
const capture = typeof options === 'boolean' ? options : options?.capture === true;
|
|
120
|
+
const list = this.listeners.get(type);
|
|
121
|
+
const at = list?.findIndex((l) => l.callback === callback && l.capture === capture) ?? -1;
|
|
122
|
+
if (list === undefined || at < 0) return;
|
|
123
|
+
(list[at] as Listener).removed = true;
|
|
124
|
+
list.splice(at, 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
dispatchEvent(event: Event): boolean {
|
|
128
|
+
const path: HostNode[] = [];
|
|
129
|
+
for (let node: HostNode | null = this; node !== null; node = node.parentNode) path.push(node);
|
|
130
|
+
const dispatch: Dispatch = {
|
|
131
|
+
target: this,
|
|
132
|
+
current: null,
|
|
133
|
+
phase: 0,
|
|
134
|
+
path,
|
|
135
|
+
stopped: false,
|
|
136
|
+
stoppedNow: false,
|
|
137
|
+
};
|
|
138
|
+
adopt(event, dispatch);
|
|
139
|
+
for (let at = path.length - 1; at > 0 && !dispatch.stopped; at -= 1) {
|
|
140
|
+
(path[at] as HostNode).invoke(event, dispatch, CAPTURING, true);
|
|
141
|
+
}
|
|
142
|
+
if (!dispatch.stopped) this.invoke(event, dispatch, AT_TARGET, true);
|
|
143
|
+
if (!dispatch.stopped) this.invoke(event, dispatch, AT_TARGET, false);
|
|
144
|
+
for (let at = 1; at < path.length && event.bubbles && !dispatch.stopped; at += 1) {
|
|
145
|
+
(path[at] as HostNode).invoke(event, dispatch, BUBBLING, false);
|
|
146
|
+
}
|
|
147
|
+
dispatch.current = null;
|
|
148
|
+
dispatch.phase = 0;
|
|
149
|
+
return !event.defaultPrevented;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `on<type>`, for each type given: a listener held in place, replaced by assignment. */
|
|
153
|
+
protected static handles(prototype: object, types: readonly string[]): void {
|
|
154
|
+
for (const type of types) {
|
|
155
|
+
Object.defineProperty(prototype, `on${type}`, {
|
|
156
|
+
configurable: true,
|
|
157
|
+
get(this: HostNode) {
|
|
158
|
+
return this.handlers.get(type)?.value ?? null;
|
|
159
|
+
},
|
|
160
|
+
set(this: HostNode, value: unknown) {
|
|
161
|
+
const held = this.handlers.get(type);
|
|
162
|
+
if (typeof value !== 'function') {
|
|
163
|
+
if (held !== undefined) this.removeEventListener(type, held.listener);
|
|
164
|
+
this.handlers.delete(type);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (held !== undefined) {
|
|
168
|
+
held.value = value;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const slot = {
|
|
172
|
+
value,
|
|
173
|
+
listener: (event: Event) => {
|
|
174
|
+
const result = (slot.value as (e: Event) => unknown).call(this, event);
|
|
175
|
+
if (result === false) event.preventDefault();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
this.handlers.set(type, slot);
|
|
179
|
+
this.addEventListener(type, slot.listener);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private invoke(event: Event, dispatch: Dispatch, phase: number, capture: boolean): void {
|
|
186
|
+
const list = this.listeners.get(event.type);
|
|
187
|
+
if (list === undefined) return;
|
|
188
|
+
dispatch.current = this;
|
|
189
|
+
dispatch.phase = phase;
|
|
190
|
+
for (const listener of [...list]) {
|
|
191
|
+
if (listener.removed || listener.capture !== capture) continue;
|
|
192
|
+
if (listener.once) this.removeEventListener(event.type, listener.callback, capture);
|
|
193
|
+
const passive = listener.passive;
|
|
194
|
+
if (passive)
|
|
195
|
+
Object.defineProperty(event, 'preventDefault', {
|
|
196
|
+
value: () => undefined,
|
|
197
|
+
configurable: true,
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
const { callback } = listener;
|
|
201
|
+
if (typeof callback === 'function') callback.call(this, event);
|
|
202
|
+
else callback.handleEvent(event);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
/*
|
|
205
|
+
* Reported, and the rest still run: a browser logs an uncaught error in a listener and
|
|
206
|
+
* carries on dispatching, where a rethrow here would end the host's process.
|
|
207
|
+
*/
|
|
208
|
+
console.error(error);
|
|
209
|
+
} finally {
|
|
210
|
+
if (passive) delete (event as { preventDefault?: unknown }).preventDefault;
|
|
211
|
+
}
|
|
212
|
+
if (dispatch.stoppedNow) return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/drs.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference types="driftscript/drs" />
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* What TypeScript knows about a `.drs` module, for the host's own compilation.
|
|
5
|
+
*
|
|
6
|
+
* The host mounts the published scenes, and the voxel sandbox imports two DriftScript modules; the
|
|
7
|
+
* demos carry this same line in `demo/voxelSandbox/drs.d.ts`, which is in the engine's compilation
|
|
8
|
+
* and not in this one. `preload.mjs` is what compiles them at run time.
|
|
9
|
+
*/
|