@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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer capture: `setPointerCapture`, `releasePointerCapture` and `hasPointerCapture` on the
|
|
3
|
+
* canvas, and `gotpointercapture` and `lostpointercapture` when it changes.
|
|
4
|
+
*
|
|
5
|
+
* **As the Pointer Events specification processes it, and in the order Chrome sends it** — measured
|
|
6
|
+
* 2026-09-19 in Chrome 151 over the DevTools protocol. A capture asked for is pending, and
|
|
7
|
+
* `hasPointerCapture` already says so; it is taken, and `gotpointercapture` sent, just before that
|
|
8
|
+
* pointer's next event. A pointer's release lets it go at once, so `lostpointercapture` falls
|
|
9
|
+
* between the `mouseup` and the `click`. A mouse with no button down cannot be captured, and the
|
|
10
|
+
* request is dropped without a word; an id that is no pointer is refused as Chrome refuses it.
|
|
11
|
+
*
|
|
12
|
+
* **What capture moves here is when things are told, not where.** A browser retargets a captured
|
|
13
|
+
* pointer's events at the capturing element; this host has one element, which is every target
|
|
14
|
+
* already, and SDL keeps a pressed pointer to the window past its edge.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { HostNode } from './domTree.ts';
|
|
18
|
+
import { type PointerInit, PointerEvent } from './uiEvents.ts';
|
|
19
|
+
|
|
20
|
+
export class PointerCapture {
|
|
21
|
+
/** Each pointer a page can capture, and whether it is pressed — in contact, or a button down. */
|
|
22
|
+
private readonly pointers = new Map<number, () => boolean>();
|
|
23
|
+
private readonly pending = new Map<number, HostNode>();
|
|
24
|
+
private readonly current = new Map<number, HostNode>();
|
|
25
|
+
|
|
26
|
+
add(pointerId: number, pressed: () => boolean): void {
|
|
27
|
+
this.pointers.set(pointerId, pressed);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
remove(pointerId: number): void {
|
|
31
|
+
this.pointers.delete(pointerId);
|
|
32
|
+
this.pending.delete(pointerId);
|
|
33
|
+
this.current.delete(pointerId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
set(element: HostNode, pointerId: number): void {
|
|
37
|
+
const pressed = this.pointers.get(pointerId);
|
|
38
|
+
if (pressed === undefined) throw notFound('setPointerCapture');
|
|
39
|
+
if (pressed()) this.pending.set(pointerId, element);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
release(element: HostNode, pointerId: number): void {
|
|
43
|
+
if (!this.pointers.has(pointerId)) throw notFound('releasePointerCapture');
|
|
44
|
+
if (this.pending.get(pointerId) === element) this.pending.delete(pointerId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
has(element: HostNode, pointerId: number): boolean {
|
|
48
|
+
return this.pending.get(pointerId) === element;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Before a pointer's next event: a change of capture, told with that event's fields. */
|
|
52
|
+
process(pointerId: number, init: PointerInit): void {
|
|
53
|
+
const was = this.current.get(pointerId);
|
|
54
|
+
const now = this.pending.get(pointerId);
|
|
55
|
+
if (was === now) return;
|
|
56
|
+
if (now === undefined) this.current.delete(pointerId);
|
|
57
|
+
else this.current.set(pointerId, now);
|
|
58
|
+
const told = { ...init, pointerId, bubbles: true, cancelable: false };
|
|
59
|
+
was?.dispatchEvent(new PointerEvent('lostpointercapture', told));
|
|
60
|
+
now?.dispatchEvent(new PointerEvent('gotpointercapture', told));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** After a pointer's up or cancel: its capture is let go, and told at once. */
|
|
64
|
+
releaseImplicitly(pointerId: number, init: PointerInit): void {
|
|
65
|
+
this.pending.delete(pointerId);
|
|
66
|
+
this.process(pointerId, init);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function notFound(method: string): Error {
|
|
71
|
+
const message = `Failed to execute '${method}' on 'Element': No active pointer with the given id is found.`;
|
|
72
|
+
return Object.assign(new Error(message), { name: 'NotFoundError' });
|
|
73
|
+
}
|
package/src/present.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting the engine's frame in the window: one full-screen triangle, reading the canvas's texture.
|
|
3
|
+
*
|
|
4
|
+
* **A browser presents a canvas by itself and a host has to.** The engine draws into the canvas's
|
|
5
|
+
* current texture and stops; this copies that texture into the window's swap chain.
|
|
6
|
+
*
|
|
7
|
+
* **Decoded to linear on the way into an sRGB swap chain**, because Dawn's window renderer hands back
|
|
8
|
+
* `bgra8unorm-srgb`, and writing into one encodes. The engine has already graded and encoded its
|
|
9
|
+
* frame, so writing its bytes as they are would encode them a second time and every picture would
|
|
10
|
+
* come out paler. Decoding first puts the engine's own bytes on the screen, within a step of
|
|
11
|
+
* rounding. **The pixel gate reads the canvas texture, not the window**, so that rounding is on the
|
|
12
|
+
* screen and never in a comparison.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const BLIT_WGSL = /* wgsl */ `
|
|
16
|
+
@group(0) @binding(0) var frame: texture_2d<f32>;
|
|
17
|
+
|
|
18
|
+
@vertex
|
|
19
|
+
fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
|
|
20
|
+
let x = f32((index << 1u) & 2u);
|
|
21
|
+
let y = f32(index & 2u);
|
|
22
|
+
return vec4f(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn toLinear(encoded: vec3f) -> vec3f {
|
|
26
|
+
let low = encoded / 12.92;
|
|
27
|
+
let high = pow((encoded + 0.055) / 1.055, vec3f(2.4));
|
|
28
|
+
return select(high, low, encoded <= vec3f(0.04045));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@fragment
|
|
32
|
+
fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
|
33
|
+
let size = vec2i(textureDimensions(frame)) - vec2i(1, 1);
|
|
34
|
+
let texel = textureLoad(frame, min(vec2i(position.xy), size), 0);
|
|
35
|
+
return vec4f(DECODE(texel.rgb), 1.0);
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/** Whether writing into `format` encodes, so the blit has to decode first. */
|
|
40
|
+
export function encodesOnWrite(format: GPUTextureFormat): boolean {
|
|
41
|
+
return format.endsWith('-srgb');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The blit's source, with the decode chosen for the swap chain it writes into. */
|
|
45
|
+
export function blitSource(target: GPUTextureFormat): string {
|
|
46
|
+
return BLIT_WGSL.replace(
|
|
47
|
+
'DECODE(texel.rgb)',
|
|
48
|
+
encodesOnWrite(target) ? 'toLinear(texel.rgb)' : 'texel.rgb',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One pipeline, and one bind group a frame texture, made when they are first needed. */
|
|
53
|
+
export class Presenter {
|
|
54
|
+
private readonly device: GPUDevice;
|
|
55
|
+
private readonly pipeline: GPURenderPipeline;
|
|
56
|
+
private source: GPUTexture | null = null;
|
|
57
|
+
private group: GPUBindGroup | null = null;
|
|
58
|
+
|
|
59
|
+
constructor(device: GPUDevice, target: GPUTextureFormat) {
|
|
60
|
+
this.device = device;
|
|
61
|
+
const module = device.createShaderModule({ label: 'native present', code: blitSource(target) });
|
|
62
|
+
this.pipeline = device.createRenderPipeline({
|
|
63
|
+
label: 'native present',
|
|
64
|
+
layout: 'auto',
|
|
65
|
+
vertex: { module, entryPoint: 'vertexMain' },
|
|
66
|
+
fragment: { module, entryPoint: 'fragmentMain', targets: [{ format: target }] },
|
|
67
|
+
primitive: { topology: 'triangle-list' },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Draw `frame` into `view`, and submit. */
|
|
72
|
+
present(frame: GPUTexture, view: GPUTextureView): void {
|
|
73
|
+
if (this.source !== frame) {
|
|
74
|
+
this.source = frame;
|
|
75
|
+
this.group = this.device.createBindGroup({
|
|
76
|
+
label: 'native present',
|
|
77
|
+
layout: this.pipeline.getBindGroupLayout(0),
|
|
78
|
+
entries: [{ binding: 0, resource: frame.createView() }],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const encoder = this.device.createCommandEncoder({ label: 'native present' });
|
|
82
|
+
const pass = encoder.beginRenderPass({
|
|
83
|
+
label: 'native present',
|
|
84
|
+
colorAttachments: [{ view, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }],
|
|
85
|
+
});
|
|
86
|
+
pass.setPipeline(this.pipeline);
|
|
87
|
+
pass.setBindGroup(0, this.group);
|
|
88
|
+
pass.draw(3);
|
|
89
|
+
pass.end();
|
|
90
|
+
this.device.queue.submit([encoder.finish()]);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/rawPad.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A joystick SDL has no controller mapping for — a flight stick, a wheel, an arcade panel, a pad
|
|
3
|
+
* too new or too rare — as Chrome shows a page one it cannot map: `mapping` empty, the device's own
|
|
4
|
+
* axes and buttons in the device's own order, and a name without "STANDARD GAMEPAD".
|
|
5
|
+
*
|
|
6
|
+
* **A hat is two axes**, as a browser on Linux reads one: the device reports it as a pair of
|
|
7
|
+
* absolute axes that are −1, 0 or 1, and SDL folds the pair into a direction, which is unfolded
|
|
8
|
+
* here — left and up negative. They follow the joystick's own axes, which is where their codes put
|
|
9
|
+
* them on a typical device.
|
|
10
|
+
*
|
|
11
|
+
* What it gives up: a device whose other axes sort after its hat in the kernel's numbering has
|
|
12
|
+
* them in a different order than Chrome gives; nothing here has been checked against a device, as
|
|
13
|
+
* there was none to hand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type DualRumble, dualRumble, type Rumbler } from './dualRumble.ts';
|
|
17
|
+
import { type HostButton, hex } from './standardPad.ts';
|
|
18
|
+
|
|
19
|
+
export interface JoystickDevice {
|
|
20
|
+
readonly id: number;
|
|
21
|
+
readonly name: string | null;
|
|
22
|
+
readonly vendor: number | null;
|
|
23
|
+
readonly product: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What this reads and drives of one of SDL's open joysticks. */
|
|
27
|
+
export interface JoystickInstance extends Rumbler {
|
|
28
|
+
readonly device: JoystickDevice;
|
|
29
|
+
readonly axes: readonly number[];
|
|
30
|
+
readonly hats: readonly string[];
|
|
31
|
+
readonly buttons: readonly boolean[];
|
|
32
|
+
readonly hasRumble: boolean;
|
|
33
|
+
close(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A hat's direction as the two axes it came from: x, then y, left and up negative. */
|
|
37
|
+
const HAT: Readonly<Record<string, readonly [number, number]>> = {
|
|
38
|
+
centered: [0, 0],
|
|
39
|
+
up: [0, -1],
|
|
40
|
+
down: [0, 1],
|
|
41
|
+
left: [-1, 0],
|
|
42
|
+
right: [1, 0],
|
|
43
|
+
leftup: [-1, -1],
|
|
44
|
+
rightup: [1, -1],
|
|
45
|
+
leftdown: [-1, 1],
|
|
46
|
+
rightdown: [1, 1],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class RawPad {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly mapping = '';
|
|
52
|
+
connected = true;
|
|
53
|
+
timestamp = 0;
|
|
54
|
+
readonly buttons: HostButton[];
|
|
55
|
+
readonly axes: number[];
|
|
56
|
+
readonly vibrationActuator: DualRumble | null;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
readonly index: number,
|
|
60
|
+
readonly source: JoystickInstance,
|
|
61
|
+
) {
|
|
62
|
+
const { name, vendor, product } = source.device;
|
|
63
|
+
this.id = `${name ?? 'Joystick'} (Vendor: ${hex(vendor)} Product: ${hex(product)})`;
|
|
64
|
+
this.buttons = source.buttons.map(() => ({ pressed: false, touched: false, value: 0 }));
|
|
65
|
+
this.axes = Array.from({ length: source.axes.length + source.hats.length * 2 }, () => 0);
|
|
66
|
+
this.vibrationActuator = source.hasRumble ? dualRumble(source) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
read(now: number): void {
|
|
70
|
+
const { axes, hats, buttons } = this.source;
|
|
71
|
+
for (let at = 0; at < axes.length; at += 1) this.axes[at] = axes[at] as number;
|
|
72
|
+
for (let at = 0; at < hats.length; at += 1) {
|
|
73
|
+
const [x, y] = HAT[hats[at] as string] ?? [0, 0];
|
|
74
|
+
this.axes[axes.length + at * 2] = x;
|
|
75
|
+
this.axes[axes.length + at * 2 + 1] = y;
|
|
76
|
+
}
|
|
77
|
+
for (let at = 0; at < buttons.length; at += 1) {
|
|
78
|
+
const button = this.buttons[at] as HostButton;
|
|
79
|
+
button.pressed = buttons[at] === true;
|
|
80
|
+
button.touched = button.pressed;
|
|
81
|
+
button.value = button.pressed ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
this.timestamp = now;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/readback.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame as the engine left it in the canvas, read back to the CPU for the pixel gate.
|
|
3
|
+
*
|
|
4
|
+
* **The canvas texture, not the window**: the window is Dawn's `bgra8unorm-srgb` swap chain, which
|
|
5
|
+
* the present decodes into, and a comparison against it would compare that round trip. The canvas
|
|
6
|
+
* texture carries `COPY_SRC` for exactly this (see `canvas.ts`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const COPY_DST = 0x08;
|
|
10
|
+
const MAP_READ = 0x01;
|
|
11
|
+
|
|
12
|
+
export interface Frame {
|
|
13
|
+
readonly width: number;
|
|
14
|
+
readonly height: number;
|
|
15
|
+
/** Top row first, four bytes a pixel, in the canvas's own channel order. */
|
|
16
|
+
readonly rgba: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Rows are copied at a stride of 256 bytes, which `copyTextureToBuffer` requires. */
|
|
20
|
+
export function paddedRow(width: number): number {
|
|
21
|
+
return 256 * Math.ceil((width * 4) / 256);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The padded rows, packed. */
|
|
25
|
+
export function unpad(bytes: Uint8Array, width: number, height: number): Uint8Array {
|
|
26
|
+
const row = paddedRow(width);
|
|
27
|
+
const out = new Uint8Array(width * height * 4);
|
|
28
|
+
for (let y = 0; y < height; y += 1) {
|
|
29
|
+
out.set(bytes.subarray(y * row, y * row + width * 4), y * width * 4);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readFrame(context: GPUCanvasContext): Promise<Frame> {
|
|
35
|
+
const configuration = context.getConfiguration();
|
|
36
|
+
if (configuration === null)
|
|
37
|
+
throw new Error('[driftengine] there is no frame: the canvas was never configured');
|
|
38
|
+
const { device, format } = configuration;
|
|
39
|
+
if (format !== 'rgba8unorm') {
|
|
40
|
+
throw new Error(`[driftengine] the gate reads rgba8unorm frames, and this one is ${format}`);
|
|
41
|
+
}
|
|
42
|
+
const texture = context.getCurrentTexture();
|
|
43
|
+
const { width, height } = texture;
|
|
44
|
+
const buffer = device.createBuffer({
|
|
45
|
+
label: 'native readback',
|
|
46
|
+
size: paddedRow(width) * height,
|
|
47
|
+
usage: COPY_DST | MAP_READ,
|
|
48
|
+
});
|
|
49
|
+
const encoder = device.createCommandEncoder({ label: 'native readback' });
|
|
50
|
+
encoder.copyTextureToBuffer(
|
|
51
|
+
{ texture },
|
|
52
|
+
{ buffer, bytesPerRow: paddedRow(width) },
|
|
53
|
+
{ width, height },
|
|
54
|
+
);
|
|
55
|
+
device.queue.submit([encoder.finish()]);
|
|
56
|
+
await buffer.mapAsync(MAP_READ);
|
|
57
|
+
const rgba = unpad(new Uint8Array(buffer.getMappedRange()), width, height);
|
|
58
|
+
buffer.unmap();
|
|
59
|
+
buffer.destroy();
|
|
60
|
+
return { width, height, rgba };
|
|
61
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host, started: a window with a WebGPU device, and around its canvas every global a page
|
|
3
|
+
* expects, installed in the order a page has them.
|
|
4
|
+
*
|
|
5
|
+
* **One start for everything that runs on the host** — a packaged game (`game.ts`) and the engine
|
|
6
|
+
* repository's scene runner and editor runner — so the three cannot come to disagree about what a
|
|
7
|
+
* page has, which is the drift two copies of one setup would be.
|
|
8
|
+
*
|
|
9
|
+
* The order is the part worth reading. **Threads first**, before a game's modules are imported,
|
|
10
|
+
* because the engine's pools decide whether they have a default worker when they load; a page's
|
|
11
|
+
* `Worker` likewise exists before its scripts run. Audio next, then the window and its device, the
|
|
12
|
+
* page, the shell's bridge (`__driftHost`), controllers, and `fetch` for the game's own files.
|
|
13
|
+
*
|
|
14
|
+
* What it gives up: one window per process, which is what a game on a desktop is.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import sdl from '@kmamal/sdl';
|
|
22
|
+
import { BRIDGE_KEY } from '@driftengine/package/bridge';
|
|
23
|
+
|
|
24
|
+
import { type AudioDefaults, installAudio } from './audio.ts';
|
|
25
|
+
import type { NativeBridge } from './bridge.ts';
|
|
26
|
+
import type { NativeCanvas } from './canvas.ts';
|
|
27
|
+
import { errorsOf } from './device.ts';
|
|
28
|
+
import { hostFetch } from './files.ts';
|
|
29
|
+
import { HostGamepads, type JoystickModule, type PadModule } from './gamepads.ts';
|
|
30
|
+
import { installGpu, installLocation } from './globals.ts';
|
|
31
|
+
import { installImages } from './images.ts';
|
|
32
|
+
import { HostPage } from './page.ts';
|
|
33
|
+
import { installThreads } from './threads.ts';
|
|
34
|
+
import { HostWindow } from './window.ts';
|
|
35
|
+
import type { WindowMode } from './windowMode.ts';
|
|
36
|
+
|
|
37
|
+
export interface HostOptions {
|
|
38
|
+
readonly title: string;
|
|
39
|
+
/** The canvas, in pixels. */
|
|
40
|
+
readonly width: number;
|
|
41
|
+
readonly height: number;
|
|
42
|
+
/** A window that is never shown, for checks that should not flash on a desktop. */
|
|
43
|
+
readonly hidden?: boolean;
|
|
44
|
+
/** Windowed, borderless or fullscreen, as a packaged game's manifest says; windowed by default. */
|
|
45
|
+
readonly mode?: WindowMode;
|
|
46
|
+
/** Whether a person can resize the window; true by default, as the manifest's default is. */
|
|
47
|
+
readonly resizable?: boolean;
|
|
48
|
+
/** Where the shell's store lives; by default where the desktop shell keeps its own. */
|
|
49
|
+
readonly storePath?: string;
|
|
50
|
+
/** The game's own files, which `fetch` of a relative address reads. */
|
|
51
|
+
readonly publicDir?: string;
|
|
52
|
+
/** `location.search`, for a game that reads its address. */
|
|
53
|
+
readonly query?: string;
|
|
54
|
+
readonly audio?: AudioDefaults;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FrameCall {
|
|
58
|
+
/** The page's clock for this frame, in milliseconds. */
|
|
59
|
+
readonly now: number;
|
|
60
|
+
/** Frames drawn before this one. */
|
|
61
|
+
readonly drawn: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RunOptions {
|
|
65
|
+
readonly frames?: number;
|
|
66
|
+
readonly before?: (drawn: number) => void;
|
|
67
|
+
readonly frame?: (call: FrameCall) => void;
|
|
68
|
+
readonly after?: (call: FrameCall) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface NativeHost {
|
|
72
|
+
readonly window: HostWindow;
|
|
73
|
+
readonly page: HostPage;
|
|
74
|
+
readonly canvas: NativeCanvas;
|
|
75
|
+
readonly bridge: NativeBridge;
|
|
76
|
+
/**
|
|
77
|
+
* Draw until the window closes, the game asks to quit, or `frames` have been drawn. Each frame
|
|
78
|
+
* calls `before` (where a held clock moves), runs the page's animation frames, calls `frame`,
|
|
79
|
+
* puts the canvas on the screen and calls `after`. Answers how many were drawn.
|
|
80
|
+
*/
|
|
81
|
+
run(options?: RunOptions): Promise<number>;
|
|
82
|
+
/** Flush the store and close the window. */
|
|
83
|
+
close(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where the desktop shell's Electron keeps application data on this platform. */
|
|
87
|
+
export function configDirectory(): string {
|
|
88
|
+
if (process.platform === 'win32') {
|
|
89
|
+
return process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming');
|
|
90
|
+
}
|
|
91
|
+
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support');
|
|
92
|
+
return process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function startHost(options: HostOptions): NativeHost {
|
|
96
|
+
installThreads();
|
|
97
|
+
installAudio(options.audio ?? {});
|
|
98
|
+
const window = new HostWindow({
|
|
99
|
+
title: options.title,
|
|
100
|
+
width: options.width,
|
|
101
|
+
height: options.height,
|
|
102
|
+
hidden: options.hidden === true,
|
|
103
|
+
mode: options.mode,
|
|
104
|
+
resizable: options.resizable,
|
|
105
|
+
});
|
|
106
|
+
installGpu(window.gpu);
|
|
107
|
+
installLocation(options.query ?? '');
|
|
108
|
+
installImages();
|
|
109
|
+
const page = new HostPage();
|
|
110
|
+
page.install();
|
|
111
|
+
window.connectPage(page);
|
|
112
|
+
|
|
113
|
+
let quitting = false;
|
|
114
|
+
const storePath =
|
|
115
|
+
options.storePath ?? join(configDirectory(), 'driftengine-native-host', 'store.json');
|
|
116
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
117
|
+
const bridge = window.createBridge(storePath, () => {
|
|
118
|
+
quitting = true;
|
|
119
|
+
});
|
|
120
|
+
(globalThis as Record<string, unknown>)[BRIDGE_KEY] = bridge;
|
|
121
|
+
new HostGamepads(sdl.controller as unknown as PadModule, {
|
|
122
|
+
joysticks: sdl.joystick as unknown as JoystickModule,
|
|
123
|
+
events: page.window,
|
|
124
|
+
}).install();
|
|
125
|
+
if (options.publicDir !== undefined) {
|
|
126
|
+
globalThis.fetch = hostFetch(options.publicDir, globalThis.fetch.bind(globalThis));
|
|
127
|
+
}
|
|
128
|
+
window.canvas.resizeTo(options.width, options.height);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
window,
|
|
132
|
+
page,
|
|
133
|
+
canvas: window.canvas,
|
|
134
|
+
bridge,
|
|
135
|
+
async run(run = {}) {
|
|
136
|
+
const frames = run.frames ?? Number.POSITIVE_INFINITY;
|
|
137
|
+
let drawn = 0;
|
|
138
|
+
while (!window.isClosed && !quitting && drawn < frames) {
|
|
139
|
+
run.before?.(drawn);
|
|
140
|
+
const now = performance.now();
|
|
141
|
+
/* The page's animation frames first, as a browser runs them before it paints. */
|
|
142
|
+
page.runFrame(now);
|
|
143
|
+
const context = window.canvas.getContext('webgpu') as GPUCanvasContext;
|
|
144
|
+
const configuration = context.getConfiguration();
|
|
145
|
+
const errors = configuration === null ? null : errorsOf(configuration.device);
|
|
146
|
+
errors?.open();
|
|
147
|
+
run.frame?.({ now, drawn });
|
|
148
|
+
window.present();
|
|
149
|
+
errors?.close();
|
|
150
|
+
run.after?.({ now, drawn });
|
|
151
|
+
drawn += 1;
|
|
152
|
+
/* A task between frames, so SDL's events and the device's callbacks get their turn. */
|
|
153
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
154
|
+
}
|
|
155
|
+
return drawn;
|
|
156
|
+
},
|
|
157
|
+
async close() {
|
|
158
|
+
bridge.flushSync();
|
|
159
|
+
await window.close();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A controller SDL knows, as the W3C's standard gamepad: SDL's game-controller layer reports a
|
|
3
|
+
* controller's buttons and axes by name, in the layout the standard describes by index, so each
|
|
4
|
+
* crosses by name into its index. `a` is button 0, the left stick is axes 0 and 1 with down
|
|
5
|
+
* positive, a trigger is an axis SDL reports from 0 to 1 and a button the standard reports as
|
|
6
|
+
* pressed past Chrome's threshold. A pad is named the way Chrome names one on Linux,
|
|
7
|
+
* `… (STANDARD GAMEPAD Vendor: 045e Product: 0b12)`, which is where the engine reads what kind of
|
|
8
|
+
* pad it is.
|
|
9
|
+
*
|
|
10
|
+
* What it gives up: trigger rumble, which the engine does not reach; and `touched`, which reads as
|
|
11
|
+
* `pressed` because SDL reports no touch.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type DualRumble, dualRumble, type Rumbler } from './dualRumble.ts';
|
|
15
|
+
|
|
16
|
+
type SdlButton =
|
|
17
|
+
| 'a'
|
|
18
|
+
| 'b'
|
|
19
|
+
| 'x'
|
|
20
|
+
| 'y'
|
|
21
|
+
| 'leftShoulder'
|
|
22
|
+
| 'rightShoulder'
|
|
23
|
+
| 'back'
|
|
24
|
+
| 'start'
|
|
25
|
+
| 'leftStick'
|
|
26
|
+
| 'rightStick'
|
|
27
|
+
| 'dpadUp'
|
|
28
|
+
| 'dpadDown'
|
|
29
|
+
| 'dpadLeft'
|
|
30
|
+
| 'dpadRight'
|
|
31
|
+
| 'guide';
|
|
32
|
+
|
|
33
|
+
type SdlAxis =
|
|
34
|
+
'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY' | 'leftTrigger' | 'rightTrigger';
|
|
35
|
+
|
|
36
|
+
export interface PadDevice {
|
|
37
|
+
readonly id: number;
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly vendor: number | null;
|
|
40
|
+
readonly product: number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What this reads and drives of one of SDL's open controllers. */
|
|
44
|
+
export interface PadController extends Rumbler {
|
|
45
|
+
readonly device: PadDevice;
|
|
46
|
+
readonly axes: Readonly<Record<SdlAxis, number>>;
|
|
47
|
+
readonly buttons: Readonly<Record<string, boolean>>;
|
|
48
|
+
readonly hasRumble: boolean;
|
|
49
|
+
close(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The standard's buttons in its order, with the two triggers standing in at 6 and 7. */
|
|
53
|
+
const STANDARD: readonly (SdlButton | 'leftTrigger' | 'rightTrigger')[] = [
|
|
54
|
+
'a',
|
|
55
|
+
'b',
|
|
56
|
+
'x',
|
|
57
|
+
'y',
|
|
58
|
+
'leftShoulder',
|
|
59
|
+
'rightShoulder',
|
|
60
|
+
'leftTrigger',
|
|
61
|
+
'rightTrigger',
|
|
62
|
+
'back',
|
|
63
|
+
'start',
|
|
64
|
+
'leftStick',
|
|
65
|
+
'rightStick',
|
|
66
|
+
'dpadUp',
|
|
67
|
+
'dpadDown',
|
|
68
|
+
'dpadLeft',
|
|
69
|
+
'dpadRight',
|
|
70
|
+
'guide',
|
|
71
|
+
];
|
|
72
|
+
const AXES: readonly SdlAxis[] = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'];
|
|
73
|
+
/** Chrome's line for an analogue button, 30 of 255 (`kDefaultButtonPressedThreshold`). */
|
|
74
|
+
const PRESSED_PAST = 30 / 255;
|
|
75
|
+
|
|
76
|
+
export const hex = (value: number | null) => (value ?? 0).toString(16).padStart(4, '0');
|
|
77
|
+
|
|
78
|
+
export interface HostButton {
|
|
79
|
+
pressed: boolean;
|
|
80
|
+
touched: boolean;
|
|
81
|
+
value: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One controller in a slot, its state rewritten in place at every read. */
|
|
85
|
+
export class StandardPad {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly mapping = 'standard';
|
|
88
|
+
connected = true;
|
|
89
|
+
timestamp = 0;
|
|
90
|
+
readonly buttons: HostButton[] = STANDARD.map(() => ({
|
|
91
|
+
pressed: false,
|
|
92
|
+
touched: false,
|
|
93
|
+
value: 0,
|
|
94
|
+
}));
|
|
95
|
+
readonly axes = [0, 0, 0, 0];
|
|
96
|
+
readonly vibrationActuator: DualRumble | null;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
readonly index: number,
|
|
100
|
+
readonly source: PadController,
|
|
101
|
+
) {
|
|
102
|
+
const { name, vendor, product } = source.device;
|
|
103
|
+
this.id = `${name} (STANDARD GAMEPAD Vendor: ${hex(vendor)} Product: ${hex(product)})`;
|
|
104
|
+
this.vibrationActuator = source.hasRumble ? dualRumble(source) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
read(now: number): void {
|
|
108
|
+
const { axes, buttons } = this.source;
|
|
109
|
+
for (let at = 0; at < STANDARD.length; at += 1) {
|
|
110
|
+
const name = STANDARD[at] as SdlButton | 'leftTrigger' | 'rightTrigger';
|
|
111
|
+
const button = this.buttons[at] as HostButton;
|
|
112
|
+
if (name === 'leftTrigger' || name === 'rightTrigger') {
|
|
113
|
+
button.value = axes[name];
|
|
114
|
+
button.pressed = button.value > PRESSED_PAST;
|
|
115
|
+
} else {
|
|
116
|
+
button.pressed = buttons[name] === true;
|
|
117
|
+
button.value = button.pressed ? 1 : 0;
|
|
118
|
+
}
|
|
119
|
+
button.touched = button.pressed;
|
|
120
|
+
}
|
|
121
|
+
for (let at = 0; at < AXES.length; at += 1) {
|
|
122
|
+
this.axes[at] = axes[AXES[at] as SdlAxis];
|
|
123
|
+
}
|
|
124
|
+
this.timestamp = now;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/textHost.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's text services on this host: `@driftengine/ui2d`'s `TextHost`, over SDL.
|
|
3
|
+
*
|
|
4
|
+
* **Committed text, as a finished composition.** SDL delivers `textInput` — a key's character, or
|
|
5
|
+
* what an input method finished composing — and each one reaches the editor through the same
|
|
6
|
+
* `applyComposition` the browser host feeds, marked done. Queued, not applied, because it arrives on
|
|
7
|
+
* SDL's schedule and the editor applies text on its own frame, as the browser host's queue does
|
|
8
|
+
* (`editor/src/host/browser/textHost.ts`). Text arriving with no field focused is not the editor's.
|
|
9
|
+
*
|
|
10
|
+
* **The clipboard is SDL's**, which is the desktop's.
|
|
11
|
+
*
|
|
12
|
+
* What it gives up, both of them `@kmamal/sdl` 0.11's: it reports no composition in progress
|
|
13
|
+
* (`textEditing`), so an input method shows its unfinished text in its own window rather than in
|
|
14
|
+
* the field; and it cannot place that window, so `focusField`'s rectangle is kept and not used.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TextHost } from '@driftengine/ui2d';
|
|
18
|
+
|
|
19
|
+
/** One update, in the shape `applyComposition` takes. */
|
|
20
|
+
export interface CompositionUpdate {
|
|
21
|
+
text: string;
|
|
22
|
+
done: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What this reads of the SDL window. */
|
|
26
|
+
export interface TextWindow {
|
|
27
|
+
on(type: 'textInput', listener: (event: { text: string }) => void): unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** SDL's clipboard. */
|
|
31
|
+
export interface TextClipboard {
|
|
32
|
+
readonly text: string;
|
|
33
|
+
setText(text: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NativeTextHost extends TextHost {
|
|
37
|
+
/** Take the text that has arrived, oldest first, and clear the queue. */
|
|
38
|
+
drainCompositions(out: CompositionUpdate[]): number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createNativeTextHost(window: TextWindow, clipboard: TextClipboard): NativeTextHost {
|
|
42
|
+
const pending: CompositionUpdate[] = [];
|
|
43
|
+
let focused = false;
|
|
44
|
+
window.on('textInput', ({ text }) => {
|
|
45
|
+
if (focused) pending.push({ text, done: true });
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
focusField() {
|
|
49
|
+
focused = true;
|
|
50
|
+
},
|
|
51
|
+
blurField() {
|
|
52
|
+
focused = false;
|
|
53
|
+
},
|
|
54
|
+
readClipboard: () => Promise.resolve(clipboard.text),
|
|
55
|
+
writeClipboard(text) {
|
|
56
|
+
clipboard.setText(text);
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
},
|
|
59
|
+
drainCompositions(out) {
|
|
60
|
+
out.length = 0;
|
|
61
|
+
for (const update of pending) out.push(update);
|
|
62
|
+
pending.length = 0;
|
|
63
|
+
return out.length;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|