@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,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A defect in `node-web-audio-api` 2.2.0's automation, corrected for the engine's ramps until it is
|
|
3
|
+
* fixed upstream.
|
|
4
|
+
*
|
|
5
|
+
* **The defect, measured against Chrome on 2026-09-19.** A `setTargetAtTime` whose start is past
|
|
6
|
+
* the first render quantum, following an event that has *settled* — the value simply set, a
|
|
7
|
+
* `setValueAtTime`, the end of a linear or exponential ramp — renders garbage from the second
|
|
8
|
+
* quantum until it starts: 31.1 for a gain holding at 1, 10.5 for one a ramp left at 0.5, a
|
|
9
|
+
* low-pass at −839,731 Hz. From its start on it is Chrome's to four places, and following another
|
|
10
|
+
* `setTargetAtTime` it is right throughout. Every other shape the engine schedules was compared
|
|
11
|
+
* and agrees. The engine's every level and cutoff move is this shape (`mix/inserts.ts`, `ramp`), so
|
|
12
|
+
* the reference mix came out 28 dB hot in its second second.
|
|
13
|
+
*
|
|
14
|
+
* **The correction is what the specification says the value already is.** Before a target starts,
|
|
15
|
+
* a parameter holds the value its last settled event left; so a `setValueAtTime` of exactly that
|
|
16
|
+
* value is placed at the target's start, which changes nothing a browser computes and gives this
|
|
17
|
+
* engine the event it renders correctly. To know that value, each parameter's scheduled events are
|
|
18
|
+
* written down as they are made. Where that record cannot say — a curve spanning the start, a
|
|
19
|
+
* `cancelAndHoldAtTime`, a value set while events are pending — nothing is added, rather than a
|
|
20
|
+
* guess.
|
|
21
|
+
*
|
|
22
|
+
* Remove this with the dependency's fix: `audioParams.test.ts` is Chrome's numbers and will say.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
|
|
27
|
+
interface Scheduled {
|
|
28
|
+
/** When it takes hold: a ramp's end, a curve's end, a target's start. */
|
|
29
|
+
readonly time: number;
|
|
30
|
+
/** Whether the value from `time` on is `value`, or still moving toward it. */
|
|
31
|
+
readonly settled: boolean;
|
|
32
|
+
readonly value: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ParamMethods = {
|
|
36
|
+
setValueAtTime(value: number, time: number): AudioParam;
|
|
37
|
+
linearRampToValueAtTime(value: number, time: number): AudioParam;
|
|
38
|
+
exponentialRampToValueAtTime(value: number, time: number): AudioParam;
|
|
39
|
+
setTargetAtTime(target: number, start: number, constant: number): AudioParam;
|
|
40
|
+
setValueCurveAtTime(values: ArrayLike<number>, start: number, duration: number): AudioParam;
|
|
41
|
+
cancelScheduledValues(time: number): AudioParam;
|
|
42
|
+
cancelAndHoldAtTime(time: number): AudioParam;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Each parameter's events, time-ordered, or `null` once the record cannot be trusted. */
|
|
46
|
+
const records = new WeakMap<object, Scheduled[] | null>();
|
|
47
|
+
|
|
48
|
+
function recordOf(param: object): Scheduled[] | null {
|
|
49
|
+
let record = records.get(param);
|
|
50
|
+
if (record === undefined) {
|
|
51
|
+
record = [];
|
|
52
|
+
records.set(param, record);
|
|
53
|
+
}
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function add(param: object, event: Scheduled): void {
|
|
58
|
+
const record = recordOf(param);
|
|
59
|
+
if (record === null) return;
|
|
60
|
+
let at = record.length;
|
|
61
|
+
while (at > 0 && (record[at - 1] as Scheduled).time > event.time) at -= 1;
|
|
62
|
+
record.splice(at, 0, event);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let installed = false;
|
|
66
|
+
|
|
67
|
+
/** Correct the Rust engine's parameters, once, for every context made after or before. */
|
|
68
|
+
export function correctAutomation(): void {
|
|
69
|
+
if (installed) return;
|
|
70
|
+
installed = true;
|
|
71
|
+
const { AudioParam: Param } = createRequire(import.meta.url)('node-web-audio-api') as {
|
|
72
|
+
AudioParam: { prototype: ParamMethods & { value: number } };
|
|
73
|
+
};
|
|
74
|
+
const proto = Param.prototype;
|
|
75
|
+
const original = {
|
|
76
|
+
setValueAtTime: proto.setValueAtTime,
|
|
77
|
+
linearRampToValueAtTime: proto.linearRampToValueAtTime,
|
|
78
|
+
exponentialRampToValueAtTime: proto.exponentialRampToValueAtTime,
|
|
79
|
+
setTargetAtTime: proto.setTargetAtTime,
|
|
80
|
+
setValueCurveAtTime: proto.setValueCurveAtTime,
|
|
81
|
+
cancelScheduledValues: proto.cancelScheduledValues,
|
|
82
|
+
cancelAndHoldAtTime: proto.cancelAndHoldAtTime,
|
|
83
|
+
};
|
|
84
|
+
const value = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
85
|
+
|
|
86
|
+
proto.setValueAtTime = function (this: AudioParam, next, time) {
|
|
87
|
+
add(this, { time, settled: true, value: next });
|
|
88
|
+
return original.setValueAtTime.call(this, next, time);
|
|
89
|
+
};
|
|
90
|
+
proto.linearRampToValueAtTime = function (this: AudioParam, next, time) {
|
|
91
|
+
add(this, { time, settled: true, value: next });
|
|
92
|
+
return original.linearRampToValueAtTime.call(this, next, time);
|
|
93
|
+
};
|
|
94
|
+
proto.exponentialRampToValueAtTime = function (this: AudioParam, next, time) {
|
|
95
|
+
add(this, { time, settled: true, value: next });
|
|
96
|
+
return original.exponentialRampToValueAtTime.call(this, next, time);
|
|
97
|
+
};
|
|
98
|
+
proto.setValueCurveAtTime = function (this: AudioParam, values, start, duration) {
|
|
99
|
+
add(this, { time: start + duration, settled: true, value: values[values.length - 1] ?? 0 });
|
|
100
|
+
/* A curve spans its duration, and a target starting inside it has no settled value to hold. */
|
|
101
|
+
add(this, { time: start, settled: false, value: Number.NaN });
|
|
102
|
+
return original.setValueCurveAtTime.call(this, values, start, duration);
|
|
103
|
+
};
|
|
104
|
+
proto.cancelScheduledValues = function (this: AudioParam, time) {
|
|
105
|
+
const record = recordOf(this);
|
|
106
|
+
if (record !== null) {
|
|
107
|
+
while (record.length > 0 && (record[record.length - 1] as Scheduled).time >= time)
|
|
108
|
+
record.pop();
|
|
109
|
+
}
|
|
110
|
+
return original.cancelScheduledValues.call(this, time);
|
|
111
|
+
};
|
|
112
|
+
proto.cancelAndHoldAtTime = function (this: AudioParam, time) {
|
|
113
|
+
records.set(this, null);
|
|
114
|
+
return original.cancelAndHoldAtTime.call(this, time);
|
|
115
|
+
};
|
|
116
|
+
proto.setTargetAtTime = function (this: AudioParam, target, start, constant) {
|
|
117
|
+
const record = recordOf(this);
|
|
118
|
+
if (record !== null) {
|
|
119
|
+
let last: Scheduled | undefined;
|
|
120
|
+
for (const event of record) if (event.time <= start) last = event;
|
|
121
|
+
/* Nothing before it: what it holds is the value the parameter was simply given. */
|
|
122
|
+
const held =
|
|
123
|
+
last === undefined
|
|
124
|
+
? (this as unknown as { value: number }).value
|
|
125
|
+
: last.settled
|
|
126
|
+
? last.value
|
|
127
|
+
: null;
|
|
128
|
+
if (held !== null && Number.isFinite(held)) {
|
|
129
|
+
add(this, { time: start, settled: true, value: held });
|
|
130
|
+
original.setValueAtTime.call(this, held, start);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
add(this, { time: start, settled: false, value: target });
|
|
134
|
+
return original.setTargetAtTime.call(this, target, start, constant);
|
|
135
|
+
};
|
|
136
|
+
if (value?.set !== undefined && value.get !== undefined) {
|
|
137
|
+
const { get, set } = value;
|
|
138
|
+
Object.defineProperty(proto, 'value', {
|
|
139
|
+
configurable: true,
|
|
140
|
+
enumerable: value.enumerable ?? false,
|
|
141
|
+
get,
|
|
142
|
+
set(this: AudioParam, next: number) {
|
|
143
|
+
/* With events pending, a value set now is an event at a time this cannot see. */
|
|
144
|
+
const record = records.get(this);
|
|
145
|
+
if (record !== undefined && record !== null && record.length > 0) records.set(this, null);
|
|
146
|
+
set.call(this, next);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's bridge, `globalThis.__driftHost`, answered by this host.
|
|
3
|
+
*
|
|
4
|
+
* **A packaged game reaches its platform through `createHost()`**, from `@driftengine/package`, which
|
|
5
|
+
* takes the shell's implementations whenever it finds this bridge — and the engine's splash stands
|
|
6
|
+
* aside for a shell. The desktop shell's preload puts one there over Electron's IPC; this host puts
|
|
7
|
+
* its own there over SDL and the filesystem, so a game packaged for one runs on the other with no
|
|
8
|
+
* change. Each member makes the decision the desktop shell's main process makes (`main/ipc.ts`), for
|
|
9
|
+
* the same reasons, written beside it.
|
|
10
|
+
*
|
|
11
|
+
* - **The store** is one file, read whole before the game boots and replaced whole a quarter of a
|
|
12
|
+
* second after the last write — `storeFile.ts`, which the desktop shell uses too — and written at
|
|
13
|
+
* once, synchronously, on the way out, because a debounce that loses its last write loses exactly
|
|
14
|
+
* the setting somebody changed before quitting.
|
|
15
|
+
* - **Fullscreen** is read back after it is asked for, since a window manager may refuse, and a
|
|
16
|
+
* size is refused while the window covers a display.
|
|
17
|
+
* - **Closing** asks a game that registered to be asked, and waits for it at most three seconds; a
|
|
18
|
+
* game that did not is closed at once.
|
|
19
|
+
*
|
|
20
|
+
* What it gives up: SDL 2 reports no scale for a display, so a display the window is not on reads
|
|
21
|
+
* 1; the store has no cloud; and `platform` answers that no store's features are here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { DisplayInfo, WindowMode } from '@driftengine/core';
|
|
25
|
+
|
|
26
|
+
import type { DriftHostBridge } from '@driftengine/package/bridge';
|
|
27
|
+
import { readStoreFile, writeStoreFile, writeStoreFileSync } from '@driftengine/package/store';
|
|
28
|
+
|
|
29
|
+
/** A display, as SDL reports one. */
|
|
30
|
+
export interface BridgeDisplay {
|
|
31
|
+
readonly name: string | null;
|
|
32
|
+
readonly frequency: number;
|
|
33
|
+
readonly geometry: {
|
|
34
|
+
readonly x: number;
|
|
35
|
+
readonly y: number;
|
|
36
|
+
readonly width: number;
|
|
37
|
+
readonly height: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the bridge reads and drives of the SDL window. */
|
|
42
|
+
export interface BridgeWindow {
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly height: number;
|
|
45
|
+
readonly pixelWidth: number;
|
|
46
|
+
readonly fullscreen: boolean;
|
|
47
|
+
readonly display: BridgeDisplay;
|
|
48
|
+
setFullscreen(on: boolean): void;
|
|
49
|
+
setSize(width: number, height: number): void;
|
|
50
|
+
on(
|
|
51
|
+
type: 'focus' | 'blur' | 'beforeClose',
|
|
52
|
+
listener: (event: { prevent?: () => void }) => void,
|
|
53
|
+
): unknown;
|
|
54
|
+
off(
|
|
55
|
+
type: 'focus' | 'blur' | 'beforeClose',
|
|
56
|
+
listener: (event: { prevent?: () => void }) => void,
|
|
57
|
+
): unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface NativeFiles {
|
|
61
|
+
openFile(accept: readonly string[]): Promise<{ name: string; bytes: Uint8Array } | null>;
|
|
62
|
+
saveFile(name: string, bytes: Uint8Array): Promise<boolean>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NativeBridgeOptions {
|
|
66
|
+
readonly window: BridgeWindow;
|
|
67
|
+
readonly displays: () => readonly BridgeDisplay[];
|
|
68
|
+
readonly storePath: string;
|
|
69
|
+
/** End the session: the host stops drawing and closes the window. */
|
|
70
|
+
readonly quit: () => void;
|
|
71
|
+
readonly files?: NativeFiles;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The bridge, and what writes its store now. */
|
|
75
|
+
export interface NativeBridge extends DriftHostBridge {
|
|
76
|
+
flushSync(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The desktop shell's figures (`main/ipc.ts`), for the same reasons. */
|
|
80
|
+
const WRITE_DEBOUNCE_MS = 250;
|
|
81
|
+
const QUIT_GRACE_MS = 3000;
|
|
82
|
+
|
|
83
|
+
const idOf = (display: BridgeDisplay) =>
|
|
84
|
+
`${display.name ?? ''}@${display.geometry.x},${display.geometry.y}`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* No file dialog on this desktop: a cancelled pick and a refused save, which a game already handles,
|
|
88
|
+
* and one line saying why, so a player whose picker never opens is told what to install.
|
|
89
|
+
*/
|
|
90
|
+
function noFiles(): NativeFiles {
|
|
91
|
+
let said = false;
|
|
92
|
+
const say = (): void => {
|
|
93
|
+
if (said) return;
|
|
94
|
+
said = true;
|
|
95
|
+
console.warn(
|
|
96
|
+
'[driftengine] this desktop has no file dialog for the native host: install zenity',
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
openFile: () => {
|
|
101
|
+
say();
|
|
102
|
+
return Promise.resolve(null);
|
|
103
|
+
},
|
|
104
|
+
saveFile: () => {
|
|
105
|
+
say();
|
|
106
|
+
return Promise.resolve(false);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function nativeBridge(options: NativeBridgeOptions): NativeBridge {
|
|
112
|
+
const { window, storePath } = options;
|
|
113
|
+
const snapshot = readStoreFile(storePath);
|
|
114
|
+
const entries = new Map(Object.entries(snapshot));
|
|
115
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
116
|
+
const flush = (): void => {
|
|
117
|
+
pending = null;
|
|
118
|
+
void writeStoreFile(storePath, entries).catch((cause: unknown) =>
|
|
119
|
+
console.error('[driftengine] could not write the store:', cause),
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
const schedule = (): void => {
|
|
123
|
+
if (pending === null) pending = setTimeout(flush, WRITE_DEBOUNCE_MS);
|
|
124
|
+
};
|
|
125
|
+
const flushSync = (): void => {
|
|
126
|
+
if (pending === null) return;
|
|
127
|
+
clearTimeout(pending);
|
|
128
|
+
pending = null;
|
|
129
|
+
try {
|
|
130
|
+
writeStoreFileSync(storePath, entries);
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
console.error('[driftengine] could not write the store on the way out:', cause);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/* One decision for the two members that need it, as the desktop shell's `resizable` is. */
|
|
137
|
+
const resizable = (): boolean => !window.fullscreen;
|
|
138
|
+
|
|
139
|
+
const quitHandlers = new Set<() => void>();
|
|
140
|
+
let grace: ReturnType<typeof setTimeout> | null = null;
|
|
141
|
+
const quit = (): void => {
|
|
142
|
+
if (grace !== null) clearTimeout(grace);
|
|
143
|
+
grace = null;
|
|
144
|
+
flushSync();
|
|
145
|
+
options.quit();
|
|
146
|
+
};
|
|
147
|
+
window.on('beforeClose', (event) => {
|
|
148
|
+
if (quitHandlers.size === 0) {
|
|
149
|
+
quit();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
event.prevent?.();
|
|
153
|
+
for (const handler of quitHandlers) handler();
|
|
154
|
+
/* A ceiling, not a wait: a game that answers with `requestQuit` closes at once. */
|
|
155
|
+
grace ??= setTimeout(quit, QUIT_GRACE_MS);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const files = options.files ?? noFiles();
|
|
159
|
+
return {
|
|
160
|
+
storeSnapshot: snapshot,
|
|
161
|
+
writeKey(key, value) {
|
|
162
|
+
entries.set(key, value);
|
|
163
|
+
schedule();
|
|
164
|
+
},
|
|
165
|
+
removeKey(key) {
|
|
166
|
+
entries.delete(key);
|
|
167
|
+
schedule();
|
|
168
|
+
},
|
|
169
|
+
flushSync,
|
|
170
|
+
setFullscreen(on) {
|
|
171
|
+
window.setFullscreen(on);
|
|
172
|
+
return Promise.resolve();
|
|
173
|
+
},
|
|
174
|
+
isFullscreen: () => window.fullscreen,
|
|
175
|
+
refreshHz: () => (window.display.frequency > 0 ? window.display.frequency : null),
|
|
176
|
+
mode: (): WindowMode => (window.fullscreen ? 'fullscreen' : 'windowed'),
|
|
177
|
+
setMode(mode) {
|
|
178
|
+
window.setFullscreen(mode === 'fullscreen');
|
|
179
|
+
/* Read back rather than assumed: a window manager may refuse. */
|
|
180
|
+
return Promise.resolve(window.fullscreen === (mode === 'fullscreen'));
|
|
181
|
+
},
|
|
182
|
+
size: () => ({ width: window.width, height: window.height }),
|
|
183
|
+
canSetSize: resizable,
|
|
184
|
+
setSize(width, height) {
|
|
185
|
+
if (!resizable()) return Promise.resolve(false);
|
|
186
|
+
window.setSize(Math.round(width), Math.round(height));
|
|
187
|
+
return Promise.resolve(true);
|
|
188
|
+
},
|
|
189
|
+
displays(): readonly DisplayInfo[] {
|
|
190
|
+
const on = idOf(window.display);
|
|
191
|
+
return options.displays().map((display, at) => ({
|
|
192
|
+
id: idOf(display),
|
|
193
|
+
/* A size where there is no name, which is what somebody choosing a monitor recognises. */
|
|
194
|
+
label:
|
|
195
|
+
display.name !== null && display.name.length > 0
|
|
196
|
+
? display.name
|
|
197
|
+
: `${display.geometry.width}x${display.geometry.height}`,
|
|
198
|
+
width: display.geometry.width,
|
|
199
|
+
height: display.geometry.height,
|
|
200
|
+
scale: idOf(display) === on ? window.pixelWidth / window.width : 1,
|
|
201
|
+
refreshHz: display.frequency > 0 ? display.frequency : null,
|
|
202
|
+
/* SDL lists the primary display first. */
|
|
203
|
+
primary: at === 0,
|
|
204
|
+
}));
|
|
205
|
+
},
|
|
206
|
+
onFocusChange(handler) {
|
|
207
|
+
const focus = () => handler(true);
|
|
208
|
+
const blur = () => handler(false);
|
|
209
|
+
window.on('focus', focus);
|
|
210
|
+
window.on('blur', blur);
|
|
211
|
+
return () => {
|
|
212
|
+
window.off('focus', focus);
|
|
213
|
+
window.off('blur', blur);
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
onQuitRequest(handler) {
|
|
217
|
+
quitHandlers.add(handler);
|
|
218
|
+
return () => quitHandlers.delete(handler);
|
|
219
|
+
},
|
|
220
|
+
canQuit: true,
|
|
221
|
+
requestQuit: quit,
|
|
222
|
+
openFile: (accept) => files.openFile(accept),
|
|
223
|
+
saveFile: (name, bytes) => files.saveFile(name, bytes),
|
|
224
|
+
platform: {
|
|
225
|
+
available: false,
|
|
226
|
+
unlockAchievement: () => undefined,
|
|
227
|
+
setRichPresence: () => undefined,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
package/src/canvas.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A canvas with no page around it: a drawing buffer, a CSS box, and a WebGPU context.
|
|
3
|
+
*
|
|
4
|
+
* **What the engine asks of a canvas, and no more.** Its WebGPU backend takes a canvas, asks it
|
|
5
|
+
* for a `webgpu` context, configures that against its device, sets the drawing buffer's size, and
|
|
6
|
+
* each frame draws into `getCurrentTexture()`. The renderer's public measurements read the canvas's
|
|
7
|
+
* CSS box. That is the whole surface, so that is what this implements — and anything a scene reaches
|
|
8
|
+
* for beyond it is a gap the host names when it meets one, rather than a document this pretends to
|
|
9
|
+
* be.
|
|
10
|
+
*
|
|
11
|
+
* **The frame lands in a texture the host owns.** A browser presents the canvas itself at the end of
|
|
12
|
+
* a task; a host has to, so the texture carries two usages the engine never asks for — sampled, to be
|
|
13
|
+
* drawn into the window, and copyable, to be read back by the pixel gate.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { CanvasStyle } from './canvasStyle.ts';
|
|
17
|
+
import { type Handlers, HostNode } from './domTree.ts';
|
|
18
|
+
import { INPUT_TYPES, type PageDocument } from './page.ts';
|
|
19
|
+
import { PointerCapture } from './pointerCapture.ts';
|
|
20
|
+
|
|
21
|
+
/* Texture usage flags, named here rather than read off a global a host may not have installed. */
|
|
22
|
+
const COPY_SRC = 0x01;
|
|
23
|
+
const TEXTURE_BINDING = 0x04;
|
|
24
|
+
const RENDER_ATTACHMENT = 0x10;
|
|
25
|
+
|
|
26
|
+
/* The attributes `HostNode.handles` defines below, declared for the checker. */
|
|
27
|
+
export interface NativeCanvas extends Handlers<(typeof INPUT_TYPES)[number]> {}
|
|
28
|
+
|
|
29
|
+
export class NativeCanvas extends HostNode {
|
|
30
|
+
/** The drawing buffer, in device pixels, which the engine sets. */
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
/** Device pixels per CSS pixel. */
|
|
34
|
+
readonly pixelRatio: number;
|
|
35
|
+
/** Whether there is a window to present to: false while it is minimised to nothing. */
|
|
36
|
+
visible = true;
|
|
37
|
+
|
|
38
|
+
private cssWidth: number;
|
|
39
|
+
private cssHeight: number;
|
|
40
|
+
private context: NativeCanvasContext | null = null;
|
|
41
|
+
/** Set by the page that holds this canvas (`page.ts`); a canvas outside one has no pointer to lock. */
|
|
42
|
+
lockPointer: (() => Promise<void>) | null = null;
|
|
43
|
+
/** Set by the page too, which is what holds the focus and the fullscreen. */
|
|
44
|
+
focuser: ((focus: boolean) => void) | null = null;
|
|
45
|
+
fullscreener: (() => Promise<void>) | null = null;
|
|
46
|
+
capture: PointerCapture | null = null;
|
|
47
|
+
/** Set by what drives the window's mouse, which is where a cursor is shown (`canvasStyle.ts`). */
|
|
48
|
+
cursorer: ((css: string) => void) | null = null;
|
|
49
|
+
readonly style = new CanvasStyle((css) => this.cursorer?.(css));
|
|
50
|
+
/**
|
|
51
|
+
* A canvas is focusable only once it is given a `tabIndex`, as a browser's is: until then it
|
|
52
|
+
* reports −1 and `focus()` does nothing.
|
|
53
|
+
*/
|
|
54
|
+
private focusIndex: number | null = null;
|
|
55
|
+
|
|
56
|
+
static {
|
|
57
|
+
HostNode.handles(NativeCanvas.prototype, INPUT_TYPES);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
constructor(width: number, height: number, pixelRatio = 1) {
|
|
61
|
+
super();
|
|
62
|
+
this.pixelRatio = pixelRatio;
|
|
63
|
+
this.width = width;
|
|
64
|
+
this.height = height;
|
|
65
|
+
this.cssWidth = width / pixelRatio;
|
|
66
|
+
this.cssHeight = height / pixelRatio;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The CSS box, which is what `cssWidth` and `aspect` are asked of. */
|
|
70
|
+
get clientWidth(): number {
|
|
71
|
+
return this.cssWidth;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get clientHeight(): number {
|
|
75
|
+
return this.cssHeight;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getBoundingClientRect(): DOMRect {
|
|
79
|
+
const width = this.cssWidth;
|
|
80
|
+
const height = this.cssHeight;
|
|
81
|
+
const rect = { x: 0, y: 0, left: 0, top: 0, right: width, bottom: height, width, height };
|
|
82
|
+
return { ...rect, toJSON: () => rect } as DOMRect;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `webgpu`, and nothing else — which is what a browser's canvas answers once it has given one.
|
|
87
|
+
* The engine never asks for another here: the backend is chosen before a context is taken.
|
|
88
|
+
*/
|
|
89
|
+
getContext(kind: string): GPUCanvasContext | null {
|
|
90
|
+
if (kind !== 'webgpu') return null;
|
|
91
|
+
if (this.context === null) this.context = new NativeCanvasContext(this);
|
|
92
|
+
return this.context as unknown as GPUCanvasContext;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get tabIndex(): number {
|
|
96
|
+
return this.focusIndex ?? -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
set tabIndex(index: number) {
|
|
100
|
+
this.focusIndex = Math.trunc(index);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Focus, through the page; nothing, as in a browser, for a canvas that is not focusable. */
|
|
104
|
+
focus(_options?: FocusOptions): void {
|
|
105
|
+
if (this.focusIndex !== null) this.focuser?.(true);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
blur(): void {
|
|
109
|
+
this.focuser?.(false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Capture, through the page, which is what knows the pointers (`pointerCapture.ts`). */
|
|
113
|
+
setPointerCapture(pointerId: number): void {
|
|
114
|
+
this.pointers().set(this, pointerId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
releasePointerCapture(pointerId: number): void {
|
|
118
|
+
this.pointers().release(this, pointerId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
hasPointerCapture(pointerId: number): boolean {
|
|
122
|
+
return this.capture?.has(this, pointerId) ?? false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The document this canvas is in, once a page holds it. */
|
|
126
|
+
get ownerDocument(): PageDocument {
|
|
127
|
+
if (this.parentNode === null) {
|
|
128
|
+
throw new Error('[driftengine] a canvas no page holds is in no document');
|
|
129
|
+
}
|
|
130
|
+
return this.parentNode as PageDocument;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Fullscreen, through the page, which is what holds the window. */
|
|
134
|
+
requestFullscreen(_options?: FullscreenOptions): Promise<void> {
|
|
135
|
+
if (this.fullscreener === null) {
|
|
136
|
+
return Promise.reject(new TypeError('[driftengine] a canvas with no window cannot fill it'));
|
|
137
|
+
}
|
|
138
|
+
return this.fullscreener();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A canvas no page holds knows no pointers, so every id is unknown to it. */
|
|
142
|
+
private pointers(): PointerCapture {
|
|
143
|
+
return this.capture ?? new PointerCapture();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What a browser's canvas answers, through the page, which is what holds the lock. */
|
|
147
|
+
requestPointerLock(_options?: unknown): Promise<void> {
|
|
148
|
+
if (this.lockPointer === null) {
|
|
149
|
+
return Promise.reject(
|
|
150
|
+
new Error('[driftengine] a canvas with no window has no pointer to lock'),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return this.lockPointer();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The window's size, in device pixels, as the host is told it.
|
|
158
|
+
*
|
|
159
|
+
* **A window minimised to nothing keeps the last size it had.** It reports zero by zero, and a
|
|
160
|
+
* texture of no size is a validation error that invalidates every command buffer that touches
|
|
161
|
+
* it; the engine sizes its targets from this canvas, so this canvas never goes to zero. `visible`
|
|
162
|
+
* says there is nowhere to present to, and the host skips presenting until there is.
|
|
163
|
+
*/
|
|
164
|
+
resizeTo(width: number, height: number): void {
|
|
165
|
+
if (width <= 0 || height <= 0) {
|
|
166
|
+
this.visible = false;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.visible = true;
|
|
170
|
+
this.width = width;
|
|
171
|
+
this.height = height;
|
|
172
|
+
this.cssWidth = width / this.pixelRatio;
|
|
173
|
+
this.cssHeight = height / this.pixelRatio;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class NativeCanvasContext {
|
|
178
|
+
readonly canvas: NativeCanvas;
|
|
179
|
+
private configuration: GPUCanvasConfiguration | null = null;
|
|
180
|
+
private texture: GPUTexture | null = null;
|
|
181
|
+
|
|
182
|
+
constructor(canvas: NativeCanvas) {
|
|
183
|
+
this.canvas = canvas;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
configure(configuration: GPUCanvasConfiguration): void {
|
|
187
|
+
this.dropTexture();
|
|
188
|
+
const asked = configuration.usage ?? RENDER_ATTACHMENT;
|
|
189
|
+
this.configuration = { ...configuration, usage: asked | TEXTURE_BINDING | COPY_SRC };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
unconfigure(): void {
|
|
193
|
+
this.dropTexture();
|
|
194
|
+
this.configuration = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getConfiguration(): GPUCanvasConfiguration | null {
|
|
198
|
+
return this.configuration;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* This frame's texture: the same one until the drawing buffer changes size, and a new one when it
|
|
203
|
+
* does, as a browser's is.
|
|
204
|
+
*/
|
|
205
|
+
getCurrentTexture(): GPUTexture {
|
|
206
|
+
const configuration = this.configuration;
|
|
207
|
+
if (configuration === null) {
|
|
208
|
+
throw new Error('[driftengine] this canvas is not configured, so it has no frame to give');
|
|
209
|
+
}
|
|
210
|
+
const { width, height } = this.canvas;
|
|
211
|
+
const current = this.texture;
|
|
212
|
+
if (current !== null && current.width === width && current.height === height) return current;
|
|
213
|
+
this.dropTexture();
|
|
214
|
+
this.texture = configuration.device.createTexture({
|
|
215
|
+
label: 'native canvas',
|
|
216
|
+
size: { width, height },
|
|
217
|
+
format: configuration.format,
|
|
218
|
+
usage: configuration.usage ?? RENDER_ATTACHMENT,
|
|
219
|
+
});
|
|
220
|
+
return this.texture;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private dropTexture(): void {
|
|
224
|
+
this.texture?.destroy();
|
|
225
|
+
this.texture = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A canvas's `style`, which a page writes to as it would a browser's: the cursor is the window's,
|
|
3
|
+
* and anything else is kept and read back, meaning nothing without a layout.
|
|
4
|
+
*
|
|
5
|
+
* **A page sets a cursor on the canvas it draws in** — `grab` over a view it can drag, `pointer`
|
|
6
|
+
* over a button, `none` in a first-person game — and a canvas with no `style` answered each with a
|
|
7
|
+
* `TypeError` inside the page's handler. CSS names more cursors than SDL has, so each is the nearest
|
|
8
|
+
* of SDL's twelve: `grab` and `grabbing` are its hand, as is `pointer`, since SDL has no open palm.
|
|
9
|
+
* A cursor with fallbacks, `url(hand.png) 4 4, crosshair`, is its last keyword; an image cursor is
|
|
10
|
+
* not drawn.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** SDL's system cursor for a CSS keyword, or null for `none`, which hides it. */
|
|
14
|
+
const SDL_CURSOR: Readonly<Record<string, string | null>> = {
|
|
15
|
+
auto: 'arrow',
|
|
16
|
+
default: 'arrow',
|
|
17
|
+
pointer: 'hand',
|
|
18
|
+
grab: 'hand',
|
|
19
|
+
grabbing: 'hand',
|
|
20
|
+
text: 'ibeam',
|
|
21
|
+
'vertical-text': 'ibeam',
|
|
22
|
+
wait: 'wait',
|
|
23
|
+
progress: 'waitarrow',
|
|
24
|
+
crosshair: 'crosshair',
|
|
25
|
+
move: 'sizeall',
|
|
26
|
+
'all-scroll': 'sizeall',
|
|
27
|
+
'not-allowed': 'no',
|
|
28
|
+
'no-drop': 'no',
|
|
29
|
+
'ew-resize': 'sizewe',
|
|
30
|
+
'col-resize': 'sizewe',
|
|
31
|
+
'e-resize': 'sizewe',
|
|
32
|
+
'w-resize': 'sizewe',
|
|
33
|
+
'ns-resize': 'sizens',
|
|
34
|
+
'row-resize': 'sizens',
|
|
35
|
+
'n-resize': 'sizens',
|
|
36
|
+
's-resize': 'sizens',
|
|
37
|
+
'nwse-resize': 'sizenwse',
|
|
38
|
+
'nw-resize': 'sizenwse',
|
|
39
|
+
'se-resize': 'sizenwse',
|
|
40
|
+
'nesw-resize': 'sizenesw',
|
|
41
|
+
'ne-resize': 'sizenesw',
|
|
42
|
+
'sw-resize': 'sizenesw',
|
|
43
|
+
none: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The SDL cursor a CSS `cursor` value asks for: null to hide it, the arrow for one SDL lacks. */
|
|
47
|
+
export function sdlCursor(css: string): string | null {
|
|
48
|
+
const keyword = css.split(',').at(-1)?.trim().toLowerCase() ?? '';
|
|
49
|
+
const cursor = SDL_CURSOR[keyword];
|
|
50
|
+
return cursor === undefined ? 'arrow' : cursor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const camel = (name: string) =>
|
|
54
|
+
name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
55
|
+
|
|
56
|
+
export class CanvasStyle {
|
|
57
|
+
[property: string]: unknown;
|
|
58
|
+
#cursor = '';
|
|
59
|
+
readonly #onCursor: (css: string) => void;
|
|
60
|
+
|
|
61
|
+
constructor(onCursor: (css: string) => void) {
|
|
62
|
+
this.#onCursor = onCursor;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get cursor(): string {
|
|
66
|
+
return this.#cursor;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set cursor(css: string) {
|
|
70
|
+
this.#cursor = css;
|
|
71
|
+
this.#onCursor(css);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setProperty(name: string, value: string): void {
|
|
75
|
+
this[camel(name)] = value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getPropertyValue(name: string): string {
|
|
79
|
+
const value = this[camel(name)];
|
|
80
|
+
return typeof value === 'string' ? value : '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
removeProperty(name: string): string {
|
|
84
|
+
const was = this.getPropertyValue(name);
|
|
85
|
+
if (camel(name) === 'cursor') this.cursor = '';
|
|
86
|
+
else delete this[camel(name)];
|
|
87
|
+
return was;
|
|
88
|
+
}
|
|
89
|
+
}
|