@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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mouse, as a page hears it from Chrome: pointer 1, and every event a browser builds around it.
|
|
3
|
+
*
|
|
4
|
+
* **What Chrome sends, measured 2026-09-19 in Chrome 151 over the DevTools protocol**, on a page
|
|
5
|
+
* whose canvas filled it, is what this sends:
|
|
6
|
+
* - Coming in is `pointerover`, `pointerenter` at the document and then the canvas, the same three as
|
|
7
|
+
* mouse events, and then the move. Going out is the reverse, the canvas before the document.
|
|
8
|
+
* - A press is `pointerdown` then `mousedown`, whose `detail` counts the clicks, and a release is
|
|
9
|
+
* `pointerup`, `mouseup` and a `click`. The click is a `PointerEvent`, as are `contextmenu` and
|
|
10
|
+
* `auxclick`, and a second click in time and place is followed by `dblclick`.
|
|
11
|
+
* - The right button opens its context menu on the press, as on Linux.
|
|
12
|
+
* - A second button pressed while one is held is a `pointermove` rather than a `pointerdown`. The
|
|
13
|
+
* first button's release after that clicks nothing, with a `detail` of 0.
|
|
14
|
+
*
|
|
15
|
+
* **Pointer lock is the cursor hidden and put back in the middle of the window after every move**,
|
|
16
|
+
* the delta being how far it got. `@kmamal/sdl` 0.11 offers no relative mouse mode and reports only
|
|
17
|
+
* where the pointer is, so this is the one way to it; it carries the desktop's acceleration, which a
|
|
18
|
+
* browser's lock without `unadjustedMovement` does too.
|
|
19
|
+
*
|
|
20
|
+
* What it gives up: clicks count by Chromium's own figures, 500 ms between presses and 2 px either
|
|
21
|
+
* way of the first, where Chrome on a desktop that sets its own uses that; a wheel notch is 100
|
|
22
|
+
* pixels, a browser's figure varying by platform; and a drag out of the window stays over the canvas
|
|
23
|
+
* until its release, which is SDL's capture of a held button.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { NativeCanvas } from './canvas.ts';
|
|
27
|
+
import { sdlCursor } from './canvasStyle.ts';
|
|
28
|
+
import type { HostPage } from './page.ts';
|
|
29
|
+
import {
|
|
30
|
+
type ModifierInit,
|
|
31
|
+
type MouseInit,
|
|
32
|
+
MouseEvent,
|
|
33
|
+
PointerEvent,
|
|
34
|
+
WheelEvent,
|
|
35
|
+
} from './uiEvents.ts';
|
|
36
|
+
|
|
37
|
+
export interface SdlPointer {
|
|
38
|
+
readonly x: number;
|
|
39
|
+
readonly y: number;
|
|
40
|
+
readonly button?: number;
|
|
41
|
+
readonly dx?: number;
|
|
42
|
+
readonly dy?: number;
|
|
43
|
+
readonly flipped?: boolean;
|
|
44
|
+
/** SDL's mouse made from a finger, which the touch events already said. */
|
|
45
|
+
readonly touch?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where the window is on the screen and how large, and SDL's mouse, which the lock warps. */
|
|
49
|
+
export interface MouseWindow {
|
|
50
|
+
readonly x: number;
|
|
51
|
+
readonly y: number;
|
|
52
|
+
readonly width: number;
|
|
53
|
+
readonly height: number;
|
|
54
|
+
}
|
|
55
|
+
export interface EventMouse {
|
|
56
|
+
setPosition(x: number, y: number): void;
|
|
57
|
+
showCursor(show?: boolean): void;
|
|
58
|
+
capture(): void;
|
|
59
|
+
uncapture(): void;
|
|
60
|
+
/** One of SDL's system cursors, by name. */
|
|
61
|
+
setCursor?(cursor: string): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** SDL counts buttons from 1: left, middle, right, back, forward. A browser counts from 0. */
|
|
65
|
+
const BUTTON = [-1, 0, 1, 2, 3, 4];
|
|
66
|
+
/** And `buttons` is a mask in which the right button comes before the middle. */
|
|
67
|
+
const BUTTON_BIT = [0, 1, 4, 2, 8, 16];
|
|
68
|
+
/** One notch of the wheel, in the pixels `deltaMode` 0 counts in. */
|
|
69
|
+
const NOTCH_PX = 100;
|
|
70
|
+
const CLICK_MS = 500;
|
|
71
|
+
const CLICK_PX = 2;
|
|
72
|
+
|
|
73
|
+
export class MouseModel {
|
|
74
|
+
private buttons = 0;
|
|
75
|
+
/** The button whose press a release would complete as a click, or −1. */
|
|
76
|
+
private pressed = -1;
|
|
77
|
+
private count = 0;
|
|
78
|
+
private lastPress = { at: Number.NEGATIVE_INFINITY, x: 0, y: 0, button: -1 };
|
|
79
|
+
private x = 0;
|
|
80
|
+
private y = 0;
|
|
81
|
+
private inside = false;
|
|
82
|
+
/** The pointer left while a button held it here, so it leaves once the last one is let go. */
|
|
83
|
+
private away = false;
|
|
84
|
+
private locked = false;
|
|
85
|
+
/** Whether the page hid the cursor, `cursor: none`, which a lock let go does not undo. */
|
|
86
|
+
private cursorHidden = false;
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
private readonly window: MouseWindow,
|
|
90
|
+
private readonly mouse: EventMouse,
|
|
91
|
+
private readonly page: HostPage,
|
|
92
|
+
private readonly canvas: NativeCanvas,
|
|
93
|
+
private readonly modifiers: () => ModifierInit,
|
|
94
|
+
private readonly now: () => number,
|
|
95
|
+
) {
|
|
96
|
+
page.setPointerLocker((lock) => this.lock(lock));
|
|
97
|
+
page.capture.add(1, () => this.buttons !== 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The cursor a page wrote to the canvas's style. */
|
|
101
|
+
cursor(css: string): void {
|
|
102
|
+
const cursor = sdlCursor(css);
|
|
103
|
+
const hidden = cursor === null;
|
|
104
|
+
if (hidden !== this.cursorHidden) {
|
|
105
|
+
this.cursorHidden = hidden;
|
|
106
|
+
if (!this.locked) this.mouse.showCursor(!hidden);
|
|
107
|
+
}
|
|
108
|
+
if (cursor !== null) this.mouse.setCursor?.(cursor);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get isLocked(): boolean {
|
|
112
|
+
return this.locked;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
move(event: SdlPointer): void {
|
|
116
|
+
let dx = event.x - this.x;
|
|
117
|
+
let dy = event.y - this.y;
|
|
118
|
+
if (this.locked) {
|
|
119
|
+
/* The warp back lands as a move to where the pointer already is: nothing moved. */
|
|
120
|
+
if (dx === 0 && dy === 0) return;
|
|
121
|
+
const centre = this.middle();
|
|
122
|
+
this.mouse.setPosition(centre.x, centre.y);
|
|
123
|
+
} else {
|
|
124
|
+
this.x = event.x;
|
|
125
|
+
this.y = event.y;
|
|
126
|
+
if (!this.inside) {
|
|
127
|
+
this.enter();
|
|
128
|
+
dx = 0;
|
|
129
|
+
dy = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
this.both('move', this.init(dx, dy, -1, 0));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
down(event: SdlPointer): void {
|
|
136
|
+
this.settle(event);
|
|
137
|
+
const button = BUTTON[event.button ?? 0] ?? -1;
|
|
138
|
+
const chord = this.buttons !== 0;
|
|
139
|
+
this.buttons |= BUTTON_BIT[event.button ?? 0] ?? 0;
|
|
140
|
+
const at = this.now();
|
|
141
|
+
const last = this.lastPress;
|
|
142
|
+
const again =
|
|
143
|
+
button === last.button &&
|
|
144
|
+
at - last.at <= CLICK_MS &&
|
|
145
|
+
Math.abs(this.x - last.x) <= CLICK_PX &&
|
|
146
|
+
Math.abs(this.y - last.y) <= CLICK_PX;
|
|
147
|
+
this.count = again ? this.count + 1 : 1;
|
|
148
|
+
this.lastPress = { at, x: this.x, y: this.y, button };
|
|
149
|
+
this.pressed = button;
|
|
150
|
+
/* A press is a person's gesture, noted before its events so their listeners may use it. */
|
|
151
|
+
this.page.userActivation.notify();
|
|
152
|
+
const init = this.init(0, 0, button, this.count);
|
|
153
|
+
this.pointer(chord ? 'pointermove' : 'pointerdown', init);
|
|
154
|
+
const mousedown = this.mouseEvent('mousedown', init);
|
|
155
|
+
if (!mousedown.defaultPrevented) this.canvas.focus();
|
|
156
|
+
/* On the press, where Chrome on Linux sends it. */
|
|
157
|
+
if (button === 2) this.pointer('contextmenu', { ...init, detail: 0 }, false);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
up(event: SdlPointer): void {
|
|
161
|
+
this.settle(event);
|
|
162
|
+
const button = BUTTON[event.button ?? 0] ?? -1;
|
|
163
|
+
this.buttons &= ~(BUTTON_BIT[event.button ?? 0] ?? 0);
|
|
164
|
+
const clicked = this.pressed === button;
|
|
165
|
+
const init = this.init(0, 0, button, clicked ? this.count : 0);
|
|
166
|
+
this.pointer(this.buttons !== 0 ? 'pointermove' : 'pointerup', init);
|
|
167
|
+
this.mouseEvent('mouseup', init);
|
|
168
|
+
if (this.buttons === 0) this.page.capture.releaseImplicitly(1, { ...init, ...POINTER(0) });
|
|
169
|
+
if (clicked) {
|
|
170
|
+
this.pointer(button === 0 ? 'click' : 'auxclick', init, false);
|
|
171
|
+
if (button === 0 && this.count === 2) this.mouseEvent('dblclick', init);
|
|
172
|
+
}
|
|
173
|
+
this.pressed = -1;
|
|
174
|
+
const { width, height } = this.window;
|
|
175
|
+
const outside = this.x < 0 || this.y < 0 || this.x >= width || this.y >= height;
|
|
176
|
+
if (this.buttons === 0 && (this.away || outside)) this.leave();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
wheel(event: SdlPointer): void {
|
|
180
|
+
this.settle(event);
|
|
181
|
+
const direction = event.flipped === true ? -1 : 1;
|
|
182
|
+
this.canvas.dispatchEvent(
|
|
183
|
+
new WheelEvent('wheel', {
|
|
184
|
+
...this.init(0, 0, -1, 0),
|
|
185
|
+
bubbles: true,
|
|
186
|
+
cancelable: true,
|
|
187
|
+
deltaX: (event.dx ?? 0) * direction * NOTCH_PX,
|
|
188
|
+
deltaY: -(event.dy ?? 0) * direction * NOTCH_PX,
|
|
189
|
+
deltaMode: 0,
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** SDL's word that the pointer is over the window again, which cancels a leave still waiting. */
|
|
195
|
+
hover(): void {
|
|
196
|
+
this.away = false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** SDL's word that the pointer left the window: out and leave, once no button holds it here. */
|
|
200
|
+
leave(): void {
|
|
201
|
+
if (!this.inside || this.locked) return;
|
|
202
|
+
if (this.buttons !== 0) {
|
|
203
|
+
this.away = true;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.away = false;
|
|
207
|
+
this.inside = false;
|
|
208
|
+
const init = this.init(0, 0, -1, 0);
|
|
209
|
+
this.pointer('pointerout', init);
|
|
210
|
+
this.boundary('pointerleave', init, [this.canvas, this.page.document]);
|
|
211
|
+
this.mouseEvent('mouseout', init);
|
|
212
|
+
this.boundary('mouseleave', init, [this.canvas, this.page.document]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A press or the wheel where no move came first: the pointer is here, so it came in. */
|
|
216
|
+
private settle(event: SdlPointer): void {
|
|
217
|
+
if (this.locked) return;
|
|
218
|
+
this.x = event.x;
|
|
219
|
+
this.y = event.y;
|
|
220
|
+
if (!this.inside) this.enter();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private enter(): void {
|
|
224
|
+
this.inside = true;
|
|
225
|
+
const init = this.init(0, 0, -1, 0);
|
|
226
|
+
this.pointer('pointerover', init);
|
|
227
|
+
this.boundary('pointerenter', init, [this.page.document, this.canvas]);
|
|
228
|
+
this.mouseEvent('mouseover', init);
|
|
229
|
+
this.boundary('mouseenter', init, [this.page.document, this.canvas]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** An enter or a leave, which does not bubble: sent to each node it crosses, in turn. */
|
|
233
|
+
private boundary(type: string, init: MouseInit, nodes: readonly EventTarget[]): void {
|
|
234
|
+
const Kind = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
|
235
|
+
const extra = type.startsWith('pointer') ? POINTER(init.buttons ?? 0) : {};
|
|
236
|
+
for (const node of nodes) {
|
|
237
|
+
node.dispatchEvent(new Kind(type, { ...init, ...extra, bubbles: false, cancelable: false }));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private both(type: string, init: MouseInit): void {
|
|
242
|
+
this.pointer(`pointer${type}`, init);
|
|
243
|
+
this.mouseEvent(`mouse${type}`, init);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private pointer(type: string, init: MouseInit, primary = true): PointerEvent {
|
|
247
|
+
/* A change of capture is told just before the pointer's next pointer event. */
|
|
248
|
+
if (type.startsWith('pointer')) {
|
|
249
|
+
this.page.capture.process(1, { ...init, ...POINTER(init.buttons ?? 0), isPrimary: true });
|
|
250
|
+
}
|
|
251
|
+
const event = new PointerEvent(type, {
|
|
252
|
+
...init,
|
|
253
|
+
...POINTER(init.buttons ?? 0),
|
|
254
|
+
isPrimary: primary,
|
|
255
|
+
detail: type === 'click' || type === 'auxclick' ? (init.detail ?? 0) : 0,
|
|
256
|
+
bubbles: true,
|
|
257
|
+
cancelable: true,
|
|
258
|
+
});
|
|
259
|
+
this.canvas.dispatchEvent(event);
|
|
260
|
+
return event;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private mouseEvent(type: string, init: MouseInit): MouseEvent {
|
|
264
|
+
const event = new MouseEvent(type, { ...init, bubbles: true, cancelable: true });
|
|
265
|
+
this.canvas.dispatchEvent(event);
|
|
266
|
+
return event;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private init(movementX: number, movementY: number, button: number, detail: number): MouseInit {
|
|
270
|
+
return {
|
|
271
|
+
clientX: this.x,
|
|
272
|
+
clientY: this.y,
|
|
273
|
+
screenX: this.window.x + this.x,
|
|
274
|
+
screenY: this.window.y + this.y,
|
|
275
|
+
movementX,
|
|
276
|
+
movementY,
|
|
277
|
+
button,
|
|
278
|
+
buttons: this.buttons,
|
|
279
|
+
detail,
|
|
280
|
+
...this.modifiers(),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private middle(): { x: number; y: number } {
|
|
285
|
+
return {
|
|
286
|
+
x: Math.round(this.window.x + this.window.width / 2),
|
|
287
|
+
y: Math.round(this.window.y + this.window.height / 2),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/*
|
|
292
|
+
* Hide the cursor and hold it, or let it go. SDL refuses to capture the mouse for a window without
|
|
293
|
+
* focus, and says so by throwing; that is a refused lock, with the cursor left as it was, and not
|
|
294
|
+
* an error to take the process down from inside a click.
|
|
295
|
+
*/
|
|
296
|
+
private lock(lock: boolean): boolean {
|
|
297
|
+
const { mouse } = this;
|
|
298
|
+
if (!lock) {
|
|
299
|
+
this.locked = false;
|
|
300
|
+
/* A release always succeeds from the page's side, whatever SDL says about it. */
|
|
301
|
+
try {
|
|
302
|
+
mouse.showCursor(!this.cursorHidden);
|
|
303
|
+
mouse.uncapture();
|
|
304
|
+
} catch {
|
|
305
|
+
/* Nothing held to let go of. */
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
mouse.showCursor(false);
|
|
311
|
+
mouse.capture();
|
|
312
|
+
const centre = this.middle();
|
|
313
|
+
mouse.setPosition(centre.x, centre.y);
|
|
314
|
+
this.x = centre.x - this.window.x;
|
|
315
|
+
this.y = centre.y - this.window.y;
|
|
316
|
+
this.locked = true;
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
mouse.showCursor(true);
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The mouse is pointer 1, always primary, one pixel across, and pressing at half pressure. */
|
|
326
|
+
function POINTER(buttons: number) {
|
|
327
|
+
return { pointerId: 1, pointerType: 'mouse', pressure: buttons !== 0 ? 0.5 : 0 };
|
|
328
|
+
}
|
package/src/page.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a scene expects around its canvas, for a host that has none: `window`, `document`, and
|
|
3
|
+
* the animation frames a browser schedules.
|
|
4
|
+
*
|
|
5
|
+
* **What is here is what gets read**, listed from every listener and property the engine's packages
|
|
6
|
+
* and the published scenes reach: keys, pointers, the mouse, the wheel, focus, resizing and
|
|
7
|
+
* unloading on `window`; visibility, focus and pointer lock on `document`; `requestAnimationFrame`,
|
|
8
|
+
* which the engine's input polls from. The three are a tree (`domTree.ts`), so an event is
|
|
9
|
+
* captured and bubbles as it does in a browser, and each answers the `on…` attributes. Anything
|
|
10
|
+
* else a scene reaches for is a gap named when it is met — `createElement` refuses by name —
|
|
11
|
+
* rather than a DOM this pretends to be.
|
|
12
|
+
*
|
|
13
|
+
* **Focus is a browser's.** The window has it or not, as SDL says; within it the canvas is focused
|
|
14
|
+
* only once it is focusable, which a canvas is when it is given a `tabIndex`, and keys go to what
|
|
15
|
+
* is focused, or to the document when nothing is.
|
|
16
|
+
*
|
|
17
|
+
* **A `document` is what the engine's splash looks for**, and it mounts one unless the scene says
|
|
18
|
+
* `splash: false` or the page carries a shell's bridge (`__driftHost`). The published scenes all
|
|
19
|
+
* say so; a packaged game is given the bridge.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { UserActivation } from './activation.ts';
|
|
23
|
+
import type { NativeCanvas } from './canvas.ts';
|
|
24
|
+
import { type Handlers, HostNode } from './domTree.ts';
|
|
25
|
+
import { installEventClasses } from './eventClasses.ts';
|
|
26
|
+
import { PageFullscreen } from './fullscreen.ts';
|
|
27
|
+
import { MediaQueries, type MediaQueryList } from './mediaQueries.ts';
|
|
28
|
+
import { PointerCapture } from './pointerCapture.ts';
|
|
29
|
+
import { FocusEvent } from './uiEvents.ts';
|
|
30
|
+
|
|
31
|
+
/** Every input event a page can be told of, which each of the three nodes answers `on…` for. */
|
|
32
|
+
export const INPUT_TYPES = [
|
|
33
|
+
...['keydown', 'keypress', 'keyup', 'focus', 'blur', 'wheel', 'contextmenu'],
|
|
34
|
+
...['click', 'dblclick', 'auxclick', 'mousedown', 'mouseup', 'mousemove'],
|
|
35
|
+
...['mouseover', 'mouseout', 'mouseenter', 'mouseleave'],
|
|
36
|
+
...['pointerdown', 'pointerup', 'pointermove', 'pointerover', 'pointerout'],
|
|
37
|
+
...['pointerenter', 'pointerleave', 'pointercancel', 'gotpointercapture', 'lostpointercapture'],
|
|
38
|
+
...['touchstart', 'touchmove', 'touchend', 'touchcancel', 'fullscreenchange', 'fullscreenerror'],
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
type InputType = (typeof INPUT_TYPES)[number];
|
|
42
|
+
const WINDOW_TYPES = ['resize', 'beforeunload', 'gamepadconnected', 'gamepaddisconnected'] as const;
|
|
43
|
+
const DOCUMENT_TYPES = [
|
|
44
|
+
'visibilitychange',
|
|
45
|
+
'pointerlockchange',
|
|
46
|
+
'pointerlockerror',
|
|
47
|
+
'fullscreenchange',
|
|
48
|
+
'fullscreenerror',
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/* The attributes `HostNode.handles` defines below, declared for the checker. */
|
|
52
|
+
export interface PageWindow extends Handlers<InputType | (typeof WINDOW_TYPES)[number]> {}
|
|
53
|
+
export interface PageDocument extends Handlers<InputType | (typeof DOCUMENT_TYPES)[number]> {}
|
|
54
|
+
|
|
55
|
+
export class PageWindow extends HostNode {
|
|
56
|
+
canvas: NativeCanvas | null = null;
|
|
57
|
+
/** Answered by the page, which knows what a query asks about (`mediaQueries.ts`). */
|
|
58
|
+
matchMedia: (query: string) => MediaQueryList = () => {
|
|
59
|
+
throw new Error('[driftengine] a window no page holds has no media to query');
|
|
60
|
+
};
|
|
61
|
+
get innerWidth(): number {
|
|
62
|
+
return this.canvas?.clientWidth ?? 0;
|
|
63
|
+
}
|
|
64
|
+
get innerHeight(): number {
|
|
65
|
+
return this.canvas?.clientHeight ?? 0;
|
|
66
|
+
}
|
|
67
|
+
get devicePixelRatio(): number {
|
|
68
|
+
return this.canvas?.pixelRatio ?? 1;
|
|
69
|
+
}
|
|
70
|
+
static {
|
|
71
|
+
HostNode.handles(PageWindow.prototype, [...INPUT_TYPES, ...WINDOW_TYPES]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class PageDocument extends HostNode {
|
|
76
|
+
visibilityState: DocumentVisibilityState = 'visible';
|
|
77
|
+
pointerLockElement: Element | null = null;
|
|
78
|
+
/** What is focused: the canvas, once it is focusable and asked, or nothing. */
|
|
79
|
+
activeElement: NativeCanvas | null = null;
|
|
80
|
+
/** Set by the page that owns this document, which is what holds the lock and the focus. */
|
|
81
|
+
release: (() => void) | null = null;
|
|
82
|
+
focused = true;
|
|
83
|
+
/** The page's fullscreen, which this document reports. */
|
|
84
|
+
fullscreenControl: PageFullscreen | null = null;
|
|
85
|
+
readonly fullscreenEnabled = true;
|
|
86
|
+
|
|
87
|
+
get fullscreenElement(): NativeCanvas | null {
|
|
88
|
+
return this.fullscreenControl?.element ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The legacy flag, still read. */
|
|
92
|
+
get fullscreen(): boolean {
|
|
93
|
+
return this.fullscreenElement !== null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
exitFullscreen(): Promise<void> {
|
|
97
|
+
return this.fullscreenControl?.exit() ?? Promise.resolve();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get hidden(): boolean {
|
|
101
|
+
return this.visibilityState !== 'visible';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
hasFocus(): boolean {
|
|
105
|
+
return this.focused;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
exitPointerLock(): void {
|
|
109
|
+
this.release?.();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
createElement(tag: string): never {
|
|
113
|
+
throw new Error(`[driftengine] the native host has no DOM to make a <${tag}> in`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static {
|
|
117
|
+
HostNode.handles(PageDocument.prototype, [...INPUT_TYPES, ...DOCUMENT_TYPES]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface HostPageOptions {
|
|
122
|
+
/** The clock a gesture's five seconds are measured on, in milliseconds. */
|
|
123
|
+
readonly now?: () => number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class HostPage {
|
|
127
|
+
readonly window = new PageWindow();
|
|
128
|
+
readonly document = new PageDocument();
|
|
129
|
+
/** Whether a person has just done something (`activation.ts`), as `navigator.userActivation`. */
|
|
130
|
+
readonly userActivation: UserActivation;
|
|
131
|
+
readonly fullscreen: PageFullscreen;
|
|
132
|
+
/** Which pointers the page holds (`pointerCapture.ts`). */
|
|
133
|
+
readonly capture = new PointerCapture();
|
|
134
|
+
readonly media: MediaQueries;
|
|
135
|
+
/** Whether a finger has landed: the one proof of a touch screen SDL gives. */
|
|
136
|
+
private touched = false;
|
|
137
|
+
/* Two queues swapped each frame, so a frame allocates nothing and asks made during it wait. */
|
|
138
|
+
private asked = new Map<number, FrameRequestCallback>();
|
|
139
|
+
private due = new Map<number, FrameRequestCallback>();
|
|
140
|
+
private nextId = 1;
|
|
141
|
+
private readonly lockListeners: ((locked: boolean) => void)[] = [];
|
|
142
|
+
private locker: ((lock: boolean) => boolean) | null = null;
|
|
143
|
+
|
|
144
|
+
constructor(options: HostPageOptions = {}) {
|
|
145
|
+
this.userActivation = new UserActivation(options.now ?? (() => performance.now()));
|
|
146
|
+
this.fullscreen = new PageFullscreen(this.userActivation);
|
|
147
|
+
this.document.parentNode = this.window;
|
|
148
|
+
this.document.release = () => this.unlock();
|
|
149
|
+
this.document.fullscreenControl = this.fullscreen;
|
|
150
|
+
this.fullscreen.changed = () => this.media.changed();
|
|
151
|
+
this.media = new MediaQueries(() => ({
|
|
152
|
+
width: this.window.innerWidth,
|
|
153
|
+
height: this.window.innerHeight,
|
|
154
|
+
pixelRatio: this.window.devicePixelRatio,
|
|
155
|
+
fullscreen: this.document.fullscreenElement !== null,
|
|
156
|
+
touched: this.touched,
|
|
157
|
+
}));
|
|
158
|
+
this.window.matchMedia = (query) => this.media.match(query);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A finger landed, so this is a touch screen, whatever SDL listed. */
|
|
162
|
+
noteTouch(): void {
|
|
163
|
+
if (this.touched) return;
|
|
164
|
+
this.touched = true;
|
|
165
|
+
this.media.changed();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
readonly requestAnimationFrame = (callback: FrameRequestCallback): number => {
|
|
169
|
+
const id = this.nextId;
|
|
170
|
+
this.nextId += 1;
|
|
171
|
+
this.asked.set(id, callback);
|
|
172
|
+
return id;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
readonly cancelAnimationFrame = (id: number): void => {
|
|
176
|
+
this.asked.delete(id);
|
|
177
|
+
this.due.delete(id);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** One frame: the callbacks asked for before it began, in the order they were asked. */
|
|
181
|
+
runFrame(now: number): void {
|
|
182
|
+
const due = this.asked;
|
|
183
|
+
this.asked = this.due;
|
|
184
|
+
this.due = due;
|
|
185
|
+
for (const [id, callback] of due) {
|
|
186
|
+
/* Looked up again, so a callback cancelled by an earlier one this frame does not run. */
|
|
187
|
+
if (!due.has(id)) continue;
|
|
188
|
+
due.delete(id);
|
|
189
|
+
callback(now);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether the window can be seen, as `document.visibilityState` reports it. */
|
|
194
|
+
setVisible(visible: boolean): void {
|
|
195
|
+
const state: DocumentVisibilityState = visible ? 'visible' : 'hidden';
|
|
196
|
+
if (this.document.visibilityState === state) return;
|
|
197
|
+
this.document.visibilityState = state;
|
|
198
|
+
this.document.dispatchEvent(new Event('visibilitychange'));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The window gaining or losing the focus: the focused canvas is told first, as the element a
|
|
203
|
+
* browser blurs, and then the window.
|
|
204
|
+
*/
|
|
205
|
+
setFocused(focused: boolean): void {
|
|
206
|
+
if (this.document.focused === focused) return;
|
|
207
|
+
this.document.focused = focused;
|
|
208
|
+
const canvas = this.document.activeElement;
|
|
209
|
+
if (canvas !== null) focusEvents(canvas, focused);
|
|
210
|
+
this.window.dispatchEvent(new FocusEvent(focused ? 'focus' : 'blur'));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Where a key goes: the focused element, or the document when nothing is focused. */
|
|
214
|
+
keyTarget(): HostNode {
|
|
215
|
+
return this.document.activeElement ?? this.document;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Hold `canvas` in this page: its parent, its pointer lock and its focus are the page's. */
|
|
219
|
+
attach(canvas: NativeCanvas): void {
|
|
220
|
+
canvas.parentNode = this.document;
|
|
221
|
+
this.window.canvas = canvas;
|
|
222
|
+
canvas.lockPointer = () => {
|
|
223
|
+
if (!this.userActivation.isActive) {
|
|
224
|
+
this.document.dispatchEvent(new Event('pointerlockerror', { bubbles: true }));
|
|
225
|
+
return Promise.reject(
|
|
226
|
+
refusal('NotAllowedError', 'A user gesture is required to request Pointer Lock.'),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return this.lock(canvas as unknown as Element)
|
|
230
|
+
? Promise.resolve()
|
|
231
|
+
: Promise.reject(
|
|
232
|
+
refusal('NotAllowedError', '[driftengine] the window could not hold the pointer'),
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
canvas.fullscreener = () => this.fullscreen.request(canvas);
|
|
236
|
+
canvas.capture = this.capture;
|
|
237
|
+
canvas.focuser = (focus) => {
|
|
238
|
+
const now = this.document.activeElement;
|
|
239
|
+
if (focus === (now === canvas)) return;
|
|
240
|
+
this.document.activeElement = focus ? canvas : null;
|
|
241
|
+
if (this.document.focused) focusEvents(canvas, focus);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The platform's half of the lock: hide the cursor and hold it, or let it go. Answers false when it
|
|
247
|
+
* cannot — a window without focus, as a hidden one is — and the lock is then refused, the way a
|
|
248
|
+
* browser refuses one: the promise rejects and `pointerlockerror` fires, and nothing is locked.
|
|
249
|
+
*/
|
|
250
|
+
setPointerLocker(locker: (lock: boolean) => boolean): void {
|
|
251
|
+
this.locker = locker;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The platform's half of fullscreen: put the window in it or take it out, false when it cannot. */
|
|
255
|
+
setFullscreener(setter: (on: boolean) => boolean): void {
|
|
256
|
+
this.fullscreen.setter = setter;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Told when the lock is taken or released. */
|
|
260
|
+
onPointerLock(listener: (locked: boolean) => void): void {
|
|
261
|
+
this.lockListeners.push(listener);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
unlock(): void {
|
|
265
|
+
if (this.document.pointerLockElement === null) return;
|
|
266
|
+
this.locker?.(false);
|
|
267
|
+
this.document.pointerLockElement = null;
|
|
268
|
+
this.changedLock(false);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Put the globals a page has on `scope`, and hand back what takes them off again. */
|
|
272
|
+
install(scope: Record<string, unknown> = globalThis as Record<string, unknown>): () => void {
|
|
273
|
+
const names = [
|
|
274
|
+
'window',
|
|
275
|
+
'document',
|
|
276
|
+
'requestAnimationFrame',
|
|
277
|
+
'cancelAnimationFrame',
|
|
278
|
+
'devicePixelRatio',
|
|
279
|
+
'matchMedia',
|
|
280
|
+
] as const;
|
|
281
|
+
const held = names.map((name) => [name, Object.getOwnPropertyDescriptor(scope, name)] as const);
|
|
282
|
+
scope['window'] = this.window;
|
|
283
|
+
scope['document'] = this.document;
|
|
284
|
+
scope['requestAnimationFrame'] = this.requestAnimationFrame;
|
|
285
|
+
scope['cancelAnimationFrame'] = this.cancelAnimationFrame;
|
|
286
|
+
scope['matchMedia'] = this.window.matchMedia;
|
|
287
|
+
Object.defineProperty(scope, 'devicePixelRatio', {
|
|
288
|
+
get: () => this.window.devicePixelRatio,
|
|
289
|
+
configurable: true,
|
|
290
|
+
});
|
|
291
|
+
const classes = installEventClasses(scope);
|
|
292
|
+
/* Node has a `navigator` of its own, behind a getter, so it is added to rather than replaced. */
|
|
293
|
+
const navigator = scope['navigator'] as object | undefined;
|
|
294
|
+
if (navigator !== undefined) {
|
|
295
|
+
Object.defineProperty(navigator, 'userActivation', {
|
|
296
|
+
value: this.userActivation,
|
|
297
|
+
configurable: true,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return () => {
|
|
301
|
+
classes();
|
|
302
|
+
if (navigator !== undefined)
|
|
303
|
+
delete (navigator as { userActivation?: unknown }).userActivation;
|
|
304
|
+
for (const [name, descriptor] of held) {
|
|
305
|
+
if (descriptor === undefined) delete scope[name];
|
|
306
|
+
else Object.defineProperty(scope, name, descriptor);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private lock(element: Element): boolean {
|
|
312
|
+
if (this.document.pointerLockElement === element) return true;
|
|
313
|
+
if (this.locker !== null && !this.locker(true)) {
|
|
314
|
+
this.document.dispatchEvent(new Event('pointerlockerror', { bubbles: true }));
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
this.document.pointerLockElement = element;
|
|
318
|
+
this.changedLock(true);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private changedLock(locked: boolean): void {
|
|
323
|
+
this.document.dispatchEvent(new Event('pointerlockchange', { bubbles: true }));
|
|
324
|
+
for (const listener of this.lockListeners) listener(locked);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `focus` and `focusin`, or `blur` and `focusout`: the first stays on the element, the second bubbles. */
|
|
329
|
+
function focusEvents(canvas: NativeCanvas, focused: boolean): void {
|
|
330
|
+
canvas.dispatchEvent(new FocusEvent(focused ? 'focus' : 'blur'));
|
|
331
|
+
canvas.dispatchEvent(new FocusEvent(focused ? 'focusin' : 'focusout', { bubbles: true }));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A `DOMException`-shaped refusal: a browser's error name, with its message. */
|
|
335
|
+
function refusal(name: string, message: string): Error {
|
|
336
|
+
return Object.assign(new Error(message), { name });
|
|
337
|
+
}
|