@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.
Files changed (134) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +29 -0
  3. package/README.md +58 -0
  4. package/dist/activation.d.ts +26 -0
  5. package/dist/activation.js +38 -0
  6. package/dist/alphaCopy.d.ts +36 -0
  7. package/dist/alphaCopy.js +178 -0
  8. package/dist/audio.d.ts +55 -0
  9. package/dist/audio.js +193 -0
  10. package/dist/audioParams.d.ts +25 -0
  11. package/dist/audioParams.js +128 -0
  12. package/dist/bridge.d.ts +70 -0
  13. package/dist/bridge.js +170 -0
  14. package/dist/canvas.d.ts +97 -0
  15. package/dist/canvas.js +197 -0
  16. package/dist/canvasStyle.d.ts +23 -0
  17. package/dist/canvasStyle.js +79 -0
  18. package/dist/device.d.ts +34 -0
  19. package/dist/device.js +144 -0
  20. package/dist/dialogs.d.ts +23 -0
  21. package/dist/dialogs.js +85 -0
  22. package/dist/domEvents.d.ts +37 -0
  23. package/dist/domEvents.js +145 -0
  24. package/dist/domTree.d.ts +34 -0
  25. package/dist/domTree.js +194 -0
  26. package/dist/dualRumble.d.ts +19 -0
  27. package/dist/dualRumble.js +47 -0
  28. package/dist/eventClasses.d.ts +12 -0
  29. package/dist/eventClasses.js +35 -0
  30. package/dist/files.d.ts +16 -0
  31. package/dist/files.js +59 -0
  32. package/dist/fullscreen.d.ts +33 -0
  33. package/dist/fullscreen.js +65 -0
  34. package/dist/game.d.ts +25 -0
  35. package/dist/game.js +38 -0
  36. package/dist/gamepads.d.ts +65 -0
  37. package/dist/gamepads.js +178 -0
  38. package/dist/globals.d.ts +24 -0
  39. package/dist/globals.js +37 -0
  40. package/dist/gpu.d.ts +26 -0
  41. package/dist/gpu.js +47 -0
  42. package/dist/images.d.ts +50 -0
  43. package/dist/images.js +111 -0
  44. package/dist/index.d.ts +23 -0
  45. package/dist/index.js +21 -0
  46. package/dist/keyCodes.d.ts +30 -0
  47. package/dist/keyCodes.js +205 -0
  48. package/dist/keyEvents.d.ts +54 -0
  49. package/dist/keyEvents.js +117 -0
  50. package/dist/mediaQueries.d.ts +62 -0
  51. package/dist/mediaQueries.js +152 -0
  52. package/dist/mouseEvents.d.ts +96 -0
  53. package/dist/mouseEvents.js +287 -0
  54. package/dist/page.d.ts +118 -0
  55. package/dist/page.js +302 -0
  56. package/dist/pointerCapture.d.ts +32 -0
  57. package/dist/pointerCapture.js +69 -0
  58. package/dist/present.d.ts +27 -0
  59. package/dist/present.js +83 -0
  60. package/dist/rawPad.d.ts +44 -0
  61. package/dist/rawPad.js +65 -0
  62. package/dist/readback.d.ts +18 -0
  63. package/dist/readback.js +46 -0
  64. package/dist/runtime.d.ts +69 -0
  65. package/dist/runtime.js +103 -0
  66. package/dist/standardPad.d.ts +49 -0
  67. package/dist/standardPad.js +80 -0
  68. package/dist/textHost.d.ts +37 -0
  69. package/dist/textHost.js +43 -0
  70. package/dist/threads.d.ts +27 -0
  71. package/dist/threads.js +59 -0
  72. package/dist/tileFiles.d.ts +16 -0
  73. package/dist/tileFiles.js +36 -0
  74. package/dist/touchEvents.d.ts +67 -0
  75. package/dist/touchEvents.js +210 -0
  76. package/dist/uiEvents.d.ts +157 -0
  77. package/dist/uiEvents.js +206 -0
  78. package/dist/uiTouch.d.ts +59 -0
  79. package/dist/uiTouch.js +75 -0
  80. package/dist/window.d.ts +71 -0
  81. package/dist/window.js +164 -0
  82. package/dist/windowMode.d.ts +19 -0
  83. package/dist/windowMode.js +15 -0
  84. package/dist/windowTitle.d.ts +18 -0
  85. package/dist/windowTitle.js +33 -0
  86. package/dist/workerScope.mjs +48 -0
  87. package/licenses/GPL-3.0.txt +674 -0
  88. package/package.json +83 -0
  89. package/src/activation.ts +42 -0
  90. package/src/alphaCopy.ts +201 -0
  91. package/src/audio.ts +246 -0
  92. package/src/audioParams.ts +150 -0
  93. package/src/bridge.ts +230 -0
  94. package/src/canvas.ts +227 -0
  95. package/src/canvasStyle.ts +89 -0
  96. package/src/device.ts +167 -0
  97. package/src/dialogs.ts +98 -0
  98. package/src/domEvents.ts +170 -0
  99. package/src/domTree.ts +215 -0
  100. package/src/drs.d.ts +9 -0
  101. package/src/dualRumble.ts +58 -0
  102. package/src/eventClasses.ts +46 -0
  103. package/src/files.ts +61 -0
  104. package/src/fixtures/grey.jpg +0 -0
  105. package/src/fixtures/rgb.png +0 -0
  106. package/src/fixtures/tone.opus +0 -0
  107. package/src/fullscreen.ts +71 -0
  108. package/src/game.ts +53 -0
  109. package/src/gamepads.ts +195 -0
  110. package/src/globals.ts +39 -0
  111. package/src/gpu.ts +59 -0
  112. package/src/images.ts +148 -0
  113. package/src/index.ts +24 -0
  114. package/src/keyCodes.ts +203 -0
  115. package/src/keyEvents.ts +135 -0
  116. package/src/mediaQueries.ts +173 -0
  117. package/src/mouseEvents.ts +328 -0
  118. package/src/page.ts +337 -0
  119. package/src/pointerCapture.ts +73 -0
  120. package/src/present.ts +92 -0
  121. package/src/rawPad.ts +85 -0
  122. package/src/readback.ts +61 -0
  123. package/src/runtime.ts +162 -0
  124. package/src/standardPad.ts +126 -0
  125. package/src/textHost.ts +66 -0
  126. package/src/threads.ts +66 -0
  127. package/src/tileFiles.ts +39 -0
  128. package/src/touchEvents.ts +238 -0
  129. package/src/uiEvents.ts +268 -0
  130. package/src/uiTouch.ts +97 -0
  131. package/src/window.ts +192 -0
  132. package/src/windowMode.ts +24 -0
  133. package/src/windowTitle.ts +36 -0
  134. package/src/workerScope.mjs +48 -0
@@ -0,0 +1,46 @@
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
+ const COPY_DST = 0x08;
9
+ const MAP_READ = 0x01;
10
+ /** Rows are copied at a stride of 256 bytes, which `copyTextureToBuffer` requires. */
11
+ export function paddedRow(width) {
12
+ return 256 * Math.ceil((width * 4) / 256);
13
+ }
14
+ /** The padded rows, packed. */
15
+ export function unpad(bytes, width, height) {
16
+ const row = paddedRow(width);
17
+ const out = new Uint8Array(width * height * 4);
18
+ for (let y = 0; y < height; y += 1) {
19
+ out.set(bytes.subarray(y * row, y * row + width * 4), y * width * 4);
20
+ }
21
+ return out;
22
+ }
23
+ export async function readFrame(context) {
24
+ const configuration = context.getConfiguration();
25
+ if (configuration === null)
26
+ throw new Error('[driftengine] there is no frame: the canvas was never configured');
27
+ const { device, format } = configuration;
28
+ if (format !== 'rgba8unorm') {
29
+ throw new Error(`[driftengine] the gate reads rgba8unorm frames, and this one is ${format}`);
30
+ }
31
+ const texture = context.getCurrentTexture();
32
+ const { width, height } = texture;
33
+ const buffer = device.createBuffer({
34
+ label: 'native readback',
35
+ size: paddedRow(width) * height,
36
+ usage: COPY_DST | MAP_READ,
37
+ });
38
+ const encoder = device.createCommandEncoder({ label: 'native readback' });
39
+ encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow: paddedRow(width) }, { width, height });
40
+ device.queue.submit([encoder.finish()]);
41
+ await buffer.mapAsync(MAP_READ);
42
+ const rgba = unpad(new Uint8Array(buffer.getMappedRange()), width, height);
43
+ buffer.unmap();
44
+ buffer.destroy();
45
+ return { width, height, rgba };
46
+ }
@@ -0,0 +1,69 @@
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
+ import { type AudioDefaults } from './audio.ts';
17
+ import type { NativeBridge } from './bridge.ts';
18
+ import type { NativeCanvas } from './canvas.ts';
19
+ import { HostPage } from './page.ts';
20
+ import { HostWindow } from './window.ts';
21
+ import type { WindowMode } from './windowMode.ts';
22
+ export interface HostOptions {
23
+ readonly title: string;
24
+ /** The canvas, in pixels. */
25
+ readonly width: number;
26
+ readonly height: number;
27
+ /** A window that is never shown, for checks that should not flash on a desktop. */
28
+ readonly hidden?: boolean;
29
+ /** Windowed, borderless or fullscreen, as a packaged game's manifest says; windowed by default. */
30
+ readonly mode?: WindowMode;
31
+ /** Whether a person can resize the window; true by default, as the manifest's default is. */
32
+ readonly resizable?: boolean;
33
+ /** Where the shell's store lives; by default where the desktop shell keeps its own. */
34
+ readonly storePath?: string;
35
+ /** The game's own files, which `fetch` of a relative address reads. */
36
+ readonly publicDir?: string;
37
+ /** `location.search`, for a game that reads its address. */
38
+ readonly query?: string;
39
+ readonly audio?: AudioDefaults;
40
+ }
41
+ export interface FrameCall {
42
+ /** The page's clock for this frame, in milliseconds. */
43
+ readonly now: number;
44
+ /** Frames drawn before this one. */
45
+ readonly drawn: number;
46
+ }
47
+ export interface RunOptions {
48
+ readonly frames?: number;
49
+ readonly before?: (drawn: number) => void;
50
+ readonly frame?: (call: FrameCall) => void;
51
+ readonly after?: (call: FrameCall) => void;
52
+ }
53
+ export interface NativeHost {
54
+ readonly window: HostWindow;
55
+ readonly page: HostPage;
56
+ readonly canvas: NativeCanvas;
57
+ readonly bridge: NativeBridge;
58
+ /**
59
+ * Draw until the window closes, the game asks to quit, or `frames` have been drawn. Each frame
60
+ * calls `before` (where a held clock moves), runs the page's animation frames, calls `frame`,
61
+ * puts the canvas on the screen and calls `after`. Answers how many were drawn.
62
+ */
63
+ run(options?: RunOptions): Promise<number>;
64
+ /** Flush the store and close the window. */
65
+ close(): Promise<void>;
66
+ }
67
+ /** Where the desktop shell's Electron keeps application data on this platform. */
68
+ export declare function configDirectory(): string;
69
+ export declare function startHost(options: HostOptions): NativeHost;
@@ -0,0 +1,103 @@
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
+ import { mkdirSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+ import sdl from '@kmamal/sdl';
20
+ import { BRIDGE_KEY } from '@driftengine/package/bridge';
21
+ import { installAudio } from './audio.js';
22
+ import { errorsOf } from './device.js';
23
+ import { hostFetch } from './files.js';
24
+ import { HostGamepads } from './gamepads.js';
25
+ import { installGpu, installLocation } from './globals.js';
26
+ import { installImages } from './images.js';
27
+ import { HostPage } from './page.js';
28
+ import { installThreads } from './threads.js';
29
+ import { HostWindow } from './window.js';
30
+ /** Where the desktop shell's Electron keeps application data on this platform. */
31
+ export function configDirectory() {
32
+ if (process.platform === 'win32') {
33
+ return process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming');
34
+ }
35
+ if (process.platform === 'darwin')
36
+ return join(homedir(), 'Library', 'Application Support');
37
+ return process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
38
+ }
39
+ export function startHost(options) {
40
+ installThreads();
41
+ installAudio(options.audio ?? {});
42
+ const window = new HostWindow({
43
+ title: options.title,
44
+ width: options.width,
45
+ height: options.height,
46
+ hidden: options.hidden === true,
47
+ mode: options.mode,
48
+ resizable: options.resizable,
49
+ });
50
+ installGpu(window.gpu);
51
+ installLocation(options.query ?? '');
52
+ installImages();
53
+ const page = new HostPage();
54
+ page.install();
55
+ window.connectPage(page);
56
+ let quitting = false;
57
+ const storePath = options.storePath ?? join(configDirectory(), 'driftengine-native-host', 'store.json');
58
+ mkdirSync(dirname(storePath), { recursive: true });
59
+ const bridge = window.createBridge(storePath, () => {
60
+ quitting = true;
61
+ });
62
+ globalThis[BRIDGE_KEY] = bridge;
63
+ new HostGamepads(sdl.controller, {
64
+ joysticks: sdl.joystick,
65
+ events: page.window,
66
+ }).install();
67
+ if (options.publicDir !== undefined) {
68
+ globalThis.fetch = hostFetch(options.publicDir, globalThis.fetch.bind(globalThis));
69
+ }
70
+ window.canvas.resizeTo(options.width, options.height);
71
+ return {
72
+ window,
73
+ page,
74
+ canvas: window.canvas,
75
+ bridge,
76
+ async run(run = {}) {
77
+ const frames = run.frames ?? Number.POSITIVE_INFINITY;
78
+ let drawn = 0;
79
+ while (!window.isClosed && !quitting && drawn < frames) {
80
+ run.before?.(drawn);
81
+ const now = performance.now();
82
+ /* The page's animation frames first, as a browser runs them before it paints. */
83
+ page.runFrame(now);
84
+ const context = window.canvas.getContext('webgpu');
85
+ const configuration = context.getConfiguration();
86
+ const errors = configuration === null ? null : errorsOf(configuration.device);
87
+ errors?.open();
88
+ run.frame?.({ now, drawn });
89
+ window.present();
90
+ errors?.close();
91
+ run.after?.({ now, drawn });
92
+ drawn += 1;
93
+ /* A task between frames, so SDL's events and the device's callbacks get their turn. */
94
+ await new Promise((resolve) => setImmediate(resolve));
95
+ }
96
+ return drawn;
97
+ },
98
+ async close() {
99
+ bridge.flushSync();
100
+ await window.close();
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,49 @@
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
+ import { type DualRumble, type Rumbler } from './dualRumble.ts';
14
+ type SdlAxis = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY' | 'leftTrigger' | 'rightTrigger';
15
+ export interface PadDevice {
16
+ readonly id: number;
17
+ readonly name: string;
18
+ readonly vendor: number | null;
19
+ readonly product: number | null;
20
+ }
21
+ /** What this reads and drives of one of SDL's open controllers. */
22
+ export interface PadController extends Rumbler {
23
+ readonly device: PadDevice;
24
+ readonly axes: Readonly<Record<SdlAxis, number>>;
25
+ readonly buttons: Readonly<Record<string, boolean>>;
26
+ readonly hasRumble: boolean;
27
+ close(): void;
28
+ }
29
+ export declare const hex: (value: number | null) => string;
30
+ export interface HostButton {
31
+ pressed: boolean;
32
+ touched: boolean;
33
+ value: number;
34
+ }
35
+ /** One controller in a slot, its state rewritten in place at every read. */
36
+ export declare class StandardPad {
37
+ readonly index: number;
38
+ readonly source: PadController;
39
+ readonly id: string;
40
+ readonly mapping = "standard";
41
+ connected: boolean;
42
+ timestamp: number;
43
+ readonly buttons: HostButton[];
44
+ readonly axes: number[];
45
+ readonly vibrationActuator: DualRumble | null;
46
+ constructor(index: number, source: PadController);
47
+ read(now: number): void;
48
+ }
49
+ export {};
@@ -0,0 +1,80 @@
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
+ import { dualRumble } from './dualRumble.js';
14
+ /** The standard's buttons in its order, with the two triggers standing in at 6 and 7. */
15
+ const STANDARD = [
16
+ 'a',
17
+ 'b',
18
+ 'x',
19
+ 'y',
20
+ 'leftShoulder',
21
+ 'rightShoulder',
22
+ 'leftTrigger',
23
+ 'rightTrigger',
24
+ 'back',
25
+ 'start',
26
+ 'leftStick',
27
+ 'rightStick',
28
+ 'dpadUp',
29
+ 'dpadDown',
30
+ 'dpadLeft',
31
+ 'dpadRight',
32
+ 'guide',
33
+ ];
34
+ const AXES = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'];
35
+ /** Chrome's line for an analogue button, 30 of 255 (`kDefaultButtonPressedThreshold`). */
36
+ const PRESSED_PAST = 30 / 255;
37
+ export const hex = (value) => (value ?? 0).toString(16).padStart(4, '0');
38
+ /** One controller in a slot, its state rewritten in place at every read. */
39
+ export class StandardPad {
40
+ index;
41
+ source;
42
+ id;
43
+ mapping = 'standard';
44
+ connected = true;
45
+ timestamp = 0;
46
+ buttons = STANDARD.map(() => ({
47
+ pressed: false,
48
+ touched: false,
49
+ value: 0,
50
+ }));
51
+ axes = [0, 0, 0, 0];
52
+ vibrationActuator;
53
+ constructor(index, source) {
54
+ this.index = index;
55
+ this.source = source;
56
+ const { name, vendor, product } = source.device;
57
+ this.id = `${name} (STANDARD GAMEPAD Vendor: ${hex(vendor)} Product: ${hex(product)})`;
58
+ this.vibrationActuator = source.hasRumble ? dualRumble(source) : null;
59
+ }
60
+ read(now) {
61
+ const { axes, buttons } = this.source;
62
+ for (let at = 0; at < STANDARD.length; at += 1) {
63
+ const name = STANDARD[at];
64
+ const button = this.buttons[at];
65
+ if (name === 'leftTrigger' || name === 'rightTrigger') {
66
+ button.value = axes[name];
67
+ button.pressed = button.value > PRESSED_PAST;
68
+ }
69
+ else {
70
+ button.pressed = buttons[name] === true;
71
+ button.value = button.pressed ? 1 : 0;
72
+ }
73
+ button.touched = button.pressed;
74
+ }
75
+ for (let at = 0; at < AXES.length; at += 1) {
76
+ this.axes[at] = axes[AXES[at]];
77
+ }
78
+ this.timestamp = now;
79
+ }
80
+ }
@@ -0,0 +1,37 @@
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
+ import type { TextHost } from '@driftengine/ui2d';
17
+ /** One update, in the shape `applyComposition` takes. */
18
+ export interface CompositionUpdate {
19
+ text: string;
20
+ done: boolean;
21
+ }
22
+ /** What this reads of the SDL window. */
23
+ export interface TextWindow {
24
+ on(type: 'textInput', listener: (event: {
25
+ text: string;
26
+ }) => void): unknown;
27
+ }
28
+ /** SDL's clipboard. */
29
+ export interface TextClipboard {
30
+ readonly text: string;
31
+ setText(text: string): void;
32
+ }
33
+ export interface NativeTextHost extends TextHost {
34
+ /** Take the text that has arrived, oldest first, and clear the queue. */
35
+ drainCompositions(out: CompositionUpdate[]): number;
36
+ }
37
+ export declare function createNativeTextHost(window: TextWindow, clipboard: TextClipboard): NativeTextHost;
@@ -0,0 +1,43 @@
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
+ export function createNativeTextHost(window, clipboard) {
17
+ const pending = [];
18
+ let focused = false;
19
+ window.on('textInput', ({ text }) => {
20
+ if (focused)
21
+ pending.push({ text, done: true });
22
+ });
23
+ return {
24
+ focusField() {
25
+ focused = true;
26
+ },
27
+ blurField() {
28
+ focused = false;
29
+ },
30
+ readClipboard: () => Promise.resolve(clipboard.text),
31
+ writeClipboard(text) {
32
+ clipboard.setText(text);
33
+ return Promise.resolve();
34
+ },
35
+ drainCompositions(out) {
36
+ out.length = 0;
37
+ for (const update of pending)
38
+ out.push(update);
39
+ pending.length = 0;
40
+ return out.length;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Real threads, as a page's `Worker`, with none of a page's conditions on shared memory.
3
+ *
4
+ * **A browser gives a page `SharedArrayBuffer` only when it is cross-origin isolated**, served with
5
+ * `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` headers: a condition on every
6
+ * consumer's deployment, which is why the engine's pools are switches rather than defaults. Node
7
+ * has shared memory unconditionally, so on this host they run on real threads with nothing asked.
8
+ *
9
+ * **The engine is not told.** Its pools build their workers the way a page does, `new Worker(url)`,
10
+ * and hear from them through `onmessage` and `error` events; this is that `Worker` over
11
+ * `node:worker_threads`, each thread starting in `workerScope.mjs`, which gives the module a
12
+ * browser worker's scope. The core count is Node's own `navigator.hardwareConcurrency`, which is
13
+ * what the physics pool bounds itself by.
14
+ *
15
+ * What it gives up: a classic, non-module worker script, which a Node thread cannot load; the
16
+ * `name` and `credentials` options, which mean nothing without a page.
17
+ */
18
+ export declare class HostWorker extends EventTarget {
19
+ onmessage: ((event: MessageEvent) => void) | null;
20
+ onerror: ((event: ErrorEvent) => void) | null;
21
+ private readonly thread;
22
+ constructor(url: string | URL, _options?: WorkerOptions);
23
+ postMessage(value: unknown, transfer?: readonly Transferable[]): void;
24
+ terminate(): void;
25
+ }
26
+ /** Answer `new Worker(url)` with a real thread, and hand back what takes it away again. */
27
+ export declare function installThreads(scope?: Record<string, unknown>): () => void;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Real threads, as a page's `Worker`, with none of a page's conditions on shared memory.
3
+ *
4
+ * **A browser gives a page `SharedArrayBuffer` only when it is cross-origin isolated**, served with
5
+ * `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` headers: a condition on every
6
+ * consumer's deployment, which is why the engine's pools are switches rather than defaults. Node
7
+ * has shared memory unconditionally, so on this host they run on real threads with nothing asked.
8
+ *
9
+ * **The engine is not told.** Its pools build their workers the way a page does, `new Worker(url)`,
10
+ * and hear from them through `onmessage` and `error` events; this is that `Worker` over
11
+ * `node:worker_threads`, each thread starting in `workerScope.mjs`, which gives the module a
12
+ * browser worker's scope. The core count is Node's own `navigator.hardwareConcurrency`, which is
13
+ * what the physics pool bounds itself by.
14
+ *
15
+ * What it gives up: a classic, non-module worker script, which a Node thread cannot load; the
16
+ * `name` and `credentials` options, which mean nothing without a page.
17
+ */
18
+ import { Worker as NodeWorker } from 'node:worker_threads';
19
+ const SCOPE = new URL('./workerScope.mjs', import.meta.url);
20
+ export class HostWorker extends EventTarget {
21
+ onmessage = null;
22
+ onerror = null;
23
+ thread;
24
+ constructor(url, _options) {
25
+ super();
26
+ this.thread = new NodeWorker(SCOPE, { workerData: { url: String(url) } });
27
+ this.thread.on('message', (data) => {
28
+ const event = Object.assign(new Event('message'), { data });
29
+ this.onmessage?.(event);
30
+ this.dispatchEvent(event);
31
+ });
32
+ this.thread.on('error', (error) => {
33
+ const event = Object.assign(new Event('error'), {
34
+ message: error.message,
35
+ error,
36
+ });
37
+ this.onerror?.(event);
38
+ this.dispatchEvent(event);
39
+ });
40
+ }
41
+ postMessage(value, transfer) {
42
+ this.thread.postMessage(value, transfer);
43
+ }
44
+ terminate() {
45
+ void this.thread.terminate();
46
+ }
47
+ }
48
+ /** Answer `new Worker(url)` with a real thread, and hand back what takes it away again. */
49
+ export function installThreads(scope = globalThis) {
50
+ const had = 'Worker' in scope;
51
+ const before = scope['Worker'];
52
+ scope['Worker'] = HostWorker;
53
+ return () => {
54
+ if (had)
55
+ scope['Worker'] = before;
56
+ else
57
+ delete scope['Worker'];
58
+ };
59
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Texture tiles from a directory on disk: the texture streamer's `TileSource`, with no network.
3
+ *
4
+ * **A tile is a file named by its hash**, the 64-bit FNV-1a `hashTile` gives as sixteen hex
5
+ * characters, which is what the streamer asks for. A name that is anything else is answered
6
+ * `null` before the filesystem is touched, so a hash from a stale or hostile index cannot climb out
7
+ * of the directory.
8
+ *
9
+ * **A file that is not there is `null`, which the streamer remembers as missing** and stops asking
10
+ * for. One that is there and cannot be read is a deployment fault rather than an absence, so it is
11
+ * said, once a tile, and then counted as missing too: the streamer treats a refusal as an absence
12
+ * either way, and a world that never finishes loading with nothing said is what it exists to
13
+ * prevent.
14
+ */
15
+ import type { TileSource } from '@driftengine/texture';
16
+ export declare function tileFiles(directory: string): TileSource;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Texture tiles from a directory on disk: the texture streamer's `TileSource`, with no network.
3
+ *
4
+ * **A tile is a file named by its hash**, the 64-bit FNV-1a `hashTile` gives as sixteen hex
5
+ * characters, which is what the streamer asks for. A name that is anything else is answered
6
+ * `null` before the filesystem is touched, so a hash from a stale or hostile index cannot climb out
7
+ * of the directory.
8
+ *
9
+ * **A file that is not there is `null`, which the streamer remembers as missing** and stops asking
10
+ * for. One that is there and cannot be read is a deployment fault rather than an absence, so it is
11
+ * said, once a tile, and then counted as missing too: the streamer treats a refusal as an absence
12
+ * either way, and a world that never finishes loading with nothing said is what it exists to
13
+ * prevent.
14
+ */
15
+ import { readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ const TILE_NAME = /^[0-9a-f]{16}$/;
18
+ export function tileFiles(directory) {
19
+ const said = new Set();
20
+ return {
21
+ async fetch(hash) {
22
+ if (!TILE_NAME.test(hash))
23
+ return null;
24
+ try {
25
+ return new Uint8Array(await readFile(join(directory, hash)));
26
+ }
27
+ catch (cause) {
28
+ if (cause.code !== 'ENOENT' && !said.has(hash)) {
29
+ said.add(hash);
30
+ console.warn(`[driftengine] tile ${hash} is on disk and could not be read:`, cause);
31
+ }
32
+ return null;
33
+ }
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Fingers, as a page hears them from Chrome: pointer events of type `touch`, touch events, and the
3
+ * mouse events and click Chrome makes of a tap.
4
+ *
5
+ * **What Chrome sends, measured 2026-09-19 in Chrome 151 over the DevTools protocol** on a page
6
+ * whose canvas filled it, is what this sends:
7
+ * - A finger down is `pointerover`, `pointerenter` at the document and the canvas, `pointerdown`,
8
+ * then `touchstart`. Fingers are pointers 2, 3 and on, never reused, the first down primary, and
9
+ * a touch's `identifier` is the lowest one free.
10
+ * - A finger is captured by the canvas when it lands, so `gotpointercapture` comes before its next
11
+ * event and `lostpointercapture` right after its `pointerup`, then `pointerout`, `pointerleave`
12
+ * at the canvas and the document, and `touchend`.
13
+ * - A `touchmove` within 15 px of where the finger landed is not sent, unless the `touchstart` was
14
+ * prevented; 13 px was held back and 17 px sent. A pointer move is always sent.
15
+ * - A tap — one finger, no further than 15 px, neither its `touchstart` nor its `touchend`
16
+ * prevented — is then a mouse: `mouseover` and `mouseenter` the first time, `mousemove`,
17
+ * `mousedown`, `mouseup`, and a `click` that is the finger's own pointer event. A second tap
18
+ * landing within 400 ms of the first's lift and 20 px of it counts two, with a `dblclick`: 340 ms
19
+ * counted and 380 did not, 20 px counted and 30 did not.
20
+ *
21
+ * **SDL makes a mouse of every finger**, and a finger of the mouse where asked; both are dropped,
22
+ * each already said by the other.
23
+ *
24
+ * What it gives up: SDL reports no contact size, so a finger is a pointer one pixel across; no
25
+ * `touchcancel`, which SDL never reports; and a `touchend` after a move is cancelable here, which
26
+ * Chrome's was not.
27
+ */
28
+ import type { NativeCanvas } from './canvas.ts';
29
+ import type { MouseWindow } from './mouseEvents.ts';
30
+ import type { HostPage } from './page.ts';
31
+ import { type ModifierInit } from './uiEvents.ts';
32
+ export interface SdlFinger {
33
+ readonly fingerId: number;
34
+ /** Across the window, from 0 to 1. */
35
+ readonly x: number;
36
+ readonly y: number;
37
+ readonly pressure: number;
38
+ /** A finger SDL made of the mouse. */
39
+ readonly mouse?: boolean;
40
+ }
41
+ export declare class TouchModel {
42
+ private readonly window;
43
+ private readonly page;
44
+ private readonly canvas;
45
+ private readonly modifiers;
46
+ private readonly now;
47
+ private readonly fingers;
48
+ private nextPointer;
49
+ /** Whether this run of touches may still be a tap: one finger, nothing prevented. */
50
+ private tappable;
51
+ private lastTap;
52
+ /** Whether the mouse Chrome makes of taps is over the canvas yet. */
53
+ private mouseOver;
54
+ constructor(window: MouseWindow, page: HostPage, canvas: NativeCanvas, modifiers: () => ModifierInit, now: () => number);
55
+ down(event: SdlFinger): void;
56
+ move(event: SdlFinger): void;
57
+ up(event: SdlFinger): void;
58
+ /** The mouse Chrome makes of a tap, and its click. */
59
+ private tap;
60
+ private init;
61
+ private pointer;
62
+ private mouse;
63
+ /** An enter or a leave, which does not bubble: sent to each node it crosses, in turn. */
64
+ private boundary;
65
+ /** A touch event for `finger`, answering whether it went uncancelled. */
66
+ private touch;
67
+ }