@driftengine/core 4.8.1 → 4.8.2

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 (62) hide show
  1. package/CHANGELOG.json +86 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.js +2 -0
  4. package/dist/input/actionMap.d.ts +6 -1
  5. package/dist/input/actionMap.js +20 -7
  6. package/dist/input/input.d.ts +28 -0
  7. package/dist/input/input.js +71 -9
  8. package/dist/input/inputHarness.d.ts +9 -0
  9. package/dist/input/inputHarness.js +34 -2
  10. package/dist/render/backend/webgl2/renderer.d.ts +52 -3
  11. package/dist/render/backend/webgl2/renderer.js +151 -5
  12. package/dist/render/backend/webgpu/buffers.d.ts +2 -0
  13. package/dist/render/backend/webgpu/buffers.js +2 -0
  14. package/dist/render/backend/webgpu/flatPass.js +16 -7
  15. package/dist/render/backend/webgpu/pipelineCache.d.ts +40 -8
  16. package/dist/render/backend/webgpu/pipelineCache.js +76 -9
  17. package/dist/render/backend/webgpu/renderer.d.ts +56 -1
  18. package/dist/render/backend/webgpu/renderer.js +151 -26
  19. package/dist/render/mapsWithoutUvs.d.ts +24 -0
  20. package/dist/render/mapsWithoutUvs.js +24 -0
  21. package/dist/render/mesh.d.ts +2 -0
  22. package/dist/render/mesh.js +3 -0
  23. package/dist/render/particlePool.d.ts +15 -0
  24. package/dist/render/particlePool.js +72 -37
  25. package/dist/render/pass.d.ts +12 -0
  26. package/dist/render/renderQuality.d.ts +7 -6
  27. package/dist/render/shaders/flat/glassTint.d.ts +28 -0
  28. package/dist/render/shaders/flat/glassTint.js +31 -0
  29. package/dist/render/shaders/flat/index.d.ts +12 -0
  30. package/dist/render/shaders/flat/index.js +7 -1
  31. package/dist/render/shaders/flat/main.d.ts +1 -1
  32. package/dist/render/shaders/flat/main.js +60 -25
  33. package/dist/render/shaders/flat/surfaceEffects.d.ts +1 -1
  34. package/dist/render/shaders/flat/surfaceEffects.js +6 -2
  35. package/dist/render/shaders/generated/flat.wgsl.d.ts +64 -0
  36. package/dist/render/shaders/generated/flat.wgsl.js +102 -38
  37. package/dist/render/surfaceTexture.d.ts +2 -0
  38. package/dist/render/surfaceTexture.js +4 -0
  39. package/dist/render/viewModel.d.ts +29 -0
  40. package/dist/render/viewModel.js +35 -0
  41. package/package.json +6 -6
  42. package/src/index.ts +9 -1
  43. package/src/input/actionMap.ts +23 -8
  44. package/src/input/input.ts +79 -9
  45. package/src/input/inputHarness.ts +27 -2
  46. package/src/render/backend/webgl2/renderer.ts +166 -13
  47. package/src/render/backend/webgpu/buffers.ts +5 -0
  48. package/src/render/backend/webgpu/flatPass.ts +41 -25
  49. package/src/render/backend/webgpu/pipelineCache.ts +82 -8
  50. package/src/render/backend/webgpu/renderer.ts +151 -26
  51. package/src/render/mapsWithoutUvs.ts +43 -0
  52. package/src/render/mesh.ts +3 -0
  53. package/src/render/particlePool.ts +101 -54
  54. package/src/render/pass.ts +12 -0
  55. package/src/render/renderQuality.ts +7 -6
  56. package/src/render/shaders/flat/glassTint.ts +48 -0
  57. package/src/render/shaders/flat/index.ts +19 -1
  58. package/src/render/shaders/flat/main.ts +60 -25
  59. package/src/render/shaders/flat/surfaceEffects.ts +6 -2
  60. package/src/render/shaders/generated/flat.wgsl.ts +102 -38
  61. package/src/render/surfaceTexture.ts +5 -0
  62. package/src/render/viewModel.ts +41 -0
@@ -199,6 +199,8 @@ export declare class SurfaceTexture {
199
199
  */
200
200
  constructor(gl: WebGL2RenderingContext, source: TexImageSource | readonly TexImageSource[], options?: SurfaceTextureOptions);
201
201
  /** Bind the effects table to `unit`, or `fallback` for a texture given none. */
202
+ /** Whether this texture carries an effects table, which the lit program reads only once one exists. */
203
+ get hasEffects(): boolean;
202
204
  bindEffects(gl: WebGL2RenderingContext, unit: number, fallback: WebGLTexture): void;
203
205
  /**
204
206
  * Replace the pixels, keeping the GPU object and its sampler state.
@@ -108,6 +108,10 @@ export class SurfaceTexture {
108
108
  }
109
109
  }
110
110
  /** Bind the effects table to `unit`, or `fallback` for a texture given none. */
111
+ /** Whether this texture carries an effects table, which the lit program reads only once one exists. */
112
+ get hasEffects() {
113
+ return this.effectsTable !== null;
114
+ }
111
115
  bindEffects(gl, unit, fallback) {
112
116
  gl.activeTexture(gl.TEXTURE0 + unit);
113
117
  gl.bindTexture(gl.TEXTURE_2D, this.effectsTable ?? fallback);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The slice of depth a view model is drawn into: the nearest share of the range.
3
+ *
4
+ * **A first-person view model — arms and a held weapon — must never go into the wall it is pushed
5
+ * against**, and clearing depth to draw it on top costs every pass that reads depth afterwards:
6
+ * ambient occlusion, depth of field and the fog all see a hole where the world was. Squeezing the
7
+ * view model's depth into the nearest sliver of the range does neither. Anything past the near
8
+ * plane in the world lands behind it, the world's depth is kept, and the view model still sorts
9
+ * against itself, at a fraction of the precision.
10
+ *
11
+ * **The near end is where the convention says it is**: 1 under reversed depth and 0 under the
12
+ * conventional sense, which a WebGL2 context without `EXT_clip_control` runs. Taken from the
13
+ * renderer's own `reversedDepth`, never from the constant, for the reason `depthConvention.ts`
14
+ * gives at length.
15
+ *
16
+ * **What it gives up**: a view model is in front of everything, so it cannot be drawn reaching
17
+ * behind a world object in its own frame — which is what a view model is for. And under temporal
18
+ * reconstruction its pixels take no motion of their own, because the motion pass matches the frame's
19
+ * depth exactly and a squeezed draw matches nothing; draw a view model with multisampling.
20
+ */
21
+ /** The share of depth a view model is drawn into when the caller names none: the nearest 1%. */
22
+ export declare const VIEW_MODEL_DEPTH_SHARE = 0.01;
23
+ /**
24
+ * Fill `out` with the `[min, max]` window depth a view model's draws are squeezed into.
25
+ *
26
+ * `share` is clamped to `[1e-4, 1]`: zero would collapse every view-model fragment onto one depth
27
+ * and lose its own sorting, and more than the whole range is no squeeze.
28
+ */
29
+ export declare function viewModelDepthRange(reversed: boolean, share: number, out: [number, number] | Float32Array): void;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The slice of depth a view model is drawn into: the nearest share of the range.
3
+ *
4
+ * **A first-person view model — arms and a held weapon — must never go into the wall it is pushed
5
+ * against**, and clearing depth to draw it on top costs every pass that reads depth afterwards:
6
+ * ambient occlusion, depth of field and the fog all see a hole where the world was. Squeezing the
7
+ * view model's depth into the nearest sliver of the range does neither. Anything past the near
8
+ * plane in the world lands behind it, the world's depth is kept, and the view model still sorts
9
+ * against itself, at a fraction of the precision.
10
+ *
11
+ * **The near end is where the convention says it is**: 1 under reversed depth and 0 under the
12
+ * conventional sense, which a WebGL2 context without `EXT_clip_control` runs. Taken from the
13
+ * renderer's own `reversedDepth`, never from the constant, for the reason `depthConvention.ts`
14
+ * gives at length.
15
+ *
16
+ * **What it gives up**: a view model is in front of everything, so it cannot be drawn reaching
17
+ * behind a world object in its own frame — which is what a view model is for. And under temporal
18
+ * reconstruction its pixels take no motion of their own, because the motion pass matches the frame's
19
+ * depth exactly and a squeezed draw matches nothing; draw a view model with multisampling.
20
+ */
21
+ /** The share of depth a view model is drawn into when the caller names none: the nearest 1%. */
22
+ export const VIEW_MODEL_DEPTH_SHARE = 0.01;
23
+ /**
24
+ * Fill `out` with the `[min, max]` window depth a view model's draws are squeezed into.
25
+ *
26
+ * `share` is clamped to `[1e-4, 1]`: zero would collapse every view-model fragment onto one depth
27
+ * and lose its own sorting, and more than the whole range is no squeeze.
28
+ */
29
+ export function viewModelDepthRange(reversed, share, out) {
30
+ const width = Number.isFinite(share)
31
+ ? Math.min(1, Math.max(1e-4, share))
32
+ : VIEW_MODEL_DEPTH_SHARE;
33
+ out[0] = reversed ? 1 - width : 0;
34
+ out[1] = reversed ? 1 : width;
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.8.1",
3
+ "version": "4.8.2",
4
4
  "description": "The runtime of DriftEngine, a WebGPU game engine with a WebGL2 fallback: renderer, loop, input, cameras, scene graph and physics",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.8.1",
33
- "@driftengine/physics": "4.8.1",
32
+ "@driftengine/drft": "4.8.2",
33
+ "@driftengine/physics": "4.8.2",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -67,8 +67,8 @@
67
67
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
68
68
  },
69
69
  "devDependencies": {
70
- "@driftengine/nav": "4.8.1",
71
- "@driftengine/network": "4.8.1",
72
- "@driftengine/texture": "4.8.1"
70
+ "@driftengine/nav": "4.8.2",
71
+ "@driftengine/network": "4.8.2",
72
+ "@driftengine/texture": "4.8.2"
73
73
  }
74
74
  }
package/src/index.ts CHANGED
@@ -743,6 +743,8 @@ export {
743
743
  REVERSED_DEPTH,
744
744
  conventionalDepth,
745
745
  } from './render/depthConvention.ts';
746
+ /* How much of the depth range `beginViewModel` takes when it is given no share. See `viewModel.ts`. */
747
+ export { VIEW_MODEL_DEPTH_SHARE } from './render/viewModel.ts';
746
748
  export type { ColliderSurfaceOptions } from './physics/colliderSurface.ts';
747
749
  export { BoxSurface } from './physics/boxSurface.ts';
748
750
  export { heightSurface } from './physics/heightSurface.ts';
@@ -864,7 +866,13 @@ export {
864
866
  export { InputSource } from './input/input.ts';
865
867
  export { isTypingTarget } from './input/typingTarget.ts';
866
868
  export type { TouchPoint, InputCallbacks } from './input/input.ts';
867
- export type { GamepadIdentity, GamepadView, InputOptions, InputSourceName } from './input/input.ts';
869
+ export type {
870
+ GamepadIdentity,
871
+ GamepadView,
872
+ InputOptions,
873
+ InputSourceName,
874
+ MouseButton,
875
+ } from './input/input.ts';
868
876
  export type { InputDevice } from './input/activeDevice.ts';
869
877
  export type { GamepadFamily } from './input/gamepadIdentity.ts';
870
878
  /* Positions rather than lettering, and the constant a consumer overrides to taste. */
@@ -1,5 +1,5 @@
1
1
  import type { KeyValueStore } from '../core/storage.ts';
2
- import type { InputSource } from './input.ts';
2
+ import type { InputSource, MouseButton } from './input.ts';
3
3
  import type { GamepadButton } from './gamepadMapping.ts';
4
4
 
5
5
  /**
@@ -18,12 +18,15 @@ import type { GamepadButton } from './gamepadMapping.ts';
18
18
  /** One thing that can satisfy an action. */
19
19
  export type Binding =
20
20
  | { readonly device: 'keyboard'; readonly code: string }
21
- | { readonly device: 'gamepad'; readonly button: GamepadButton };
21
+ | { readonly device: 'gamepad'; readonly button: GamepadButton }
22
+ | { readonly device: 'mouse'; readonly button: MouseButton };
22
23
 
23
24
  /** Jump, fire, pause: a state, bound to keys and buttons. */
24
25
  export interface DigitalAction {
25
26
  readonly keys?: readonly string[];
26
27
  readonly buttons?: readonly GamepadButton[];
28
+ /** Mouse buttons, pressed over the input source's target: fire on the left, aim on the right. */
29
+ readonly mouseButtons?: readonly MouseButton[];
27
30
  }
28
31
 
29
32
  /**
@@ -92,7 +95,9 @@ export class ActionMap {
92
95
  return this.any(action, (binding) =>
93
96
  binding.device === 'keyboard'
94
97
  ? this.input.isDown(binding.code)
95
- : (this.input.pad(0)?.down(binding.button) ?? false),
98
+ : binding.device === 'mouse'
99
+ ? this.input.mouseDown(binding.button)
100
+ : (this.input.pad(0)?.down(binding.button) ?? false),
96
101
  );
97
102
  }
98
103
 
@@ -101,7 +106,9 @@ export class ActionMap {
101
106
  return this.any(action, (binding) =>
102
107
  binding.device === 'keyboard'
103
108
  ? this.input.keyPressed(binding.code)
104
- : (this.input.pad(0)?.pressed(binding.button) ?? false),
109
+ : binding.device === 'mouse'
110
+ ? this.input.mousePressed(binding.button)
111
+ : (this.input.pad(0)?.pressed(binding.button) ?? false),
105
112
  );
106
113
  }
107
114
 
@@ -118,6 +125,8 @@ export class ActionMap {
118
125
  for (const binding of bindings) {
119
126
  if (binding.device === 'keyboard') {
120
127
  if (this.input.consumeKeyPress(binding.code)) return true;
128
+ } else if (binding.device === 'mouse') {
129
+ if (this.input.consumeMousePress(binding.button)) return true;
121
130
  } else if (this.input.pad(0)?.consumePress(binding.button) === true) {
122
131
  return true;
123
132
  }
@@ -354,16 +363,19 @@ function bindingsOf(definition: DigitalAction): Binding[] {
354
363
  const list: Binding[] = [];
355
364
  for (const code of definition.keys ?? []) list.push({ device: 'keyboard', code });
356
365
  for (const button of definition.buttons ?? []) list.push({ device: 'gamepad', button });
366
+ for (const button of definition.mouseButtons ?? []) list.push({ device: 'mouse', button });
357
367
  return list;
358
368
  }
359
369
 
360
370
  function sameBinding(a: Binding, b: Binding): boolean {
361
- if (a.device !== b.device) return false;
362
- return a.device === 'keyboard' && b.device === 'keyboard'
363
- ? a.code === b.code
364
- : a.device === 'gamepad' && b.device === 'gamepad' && a.button === b.button;
371
+ if (a.device === 'keyboard' || b.device === 'keyboard') {
372
+ return a.device === 'keyboard' && b.device === 'keyboard' && a.code === b.code;
373
+ }
374
+ return a.device === b.device && a.button === b.button;
365
375
  }
366
376
 
377
+ const MOUSE_BUTTONS: ReadonlySet<string> = new Set(['left', 'middle', 'right', 'back', 'forward']);
378
+
367
379
  function sameList(a: readonly Binding[], b: readonly Binding[]): boolean {
368
380
  return a.length === b.length && a.every((entry, at) => sameBinding(entry, b[at] as Binding));
369
381
  }
@@ -373,5 +385,8 @@ function isBinding(value: unknown): value is Binding {
373
385
  if (typeof value !== 'object' || value === null) return false;
374
386
  const candidate = value as { device?: unknown; code?: unknown; button?: unknown };
375
387
  if (candidate.device === 'keyboard') return typeof candidate.code === 'string';
388
+ if (candidate.device === 'mouse') {
389
+ return typeof candidate.button === 'string' && MOUSE_BUTTONS.has(candidate.button);
390
+ }
376
391
  return candidate.device === 'gamepad' && typeof candidate.button === 'string';
377
392
  }
@@ -104,6 +104,23 @@ export interface TouchPoint {
104
104
  /** The devices this source watches, and the granularity a consumer may switch off. */
105
105
  export type InputSourceName = 'keyboard' | 'mouse' | 'touch' | 'gamepad';
106
106
 
107
+ /** A mouse button by what it is, in `MouseEvent.button` order. */
108
+ export type MouseButton = 'left' | 'middle' | 'right' | 'back' | 'forward';
109
+
110
+ /** One bit a button, so the held, pressed and claimed states are integers and a read allocates nothing. */
111
+ const MOUSE_BIT: Readonly<Record<MouseButton, number>> = {
112
+ left: 1,
113
+ middle: 2,
114
+ right: 4,
115
+ back: 8,
116
+ forward: 16,
117
+ };
118
+
119
+ /** `MouseEvent.button` as a bit, or 0 for a button past the five this names. */
120
+ function mouseBit(button: number): number {
121
+ return button >= 0 && button < 5 ? 1 << button : 0;
122
+ }
123
+
107
124
  export interface InputOptions {
108
125
  /**
109
126
  * Turn a device off. Absent means on.
@@ -420,6 +437,19 @@ export class InputSource {
420
437
  private mouseDx = 0;
421
438
  private mouseDy = 0;
422
439
  private mouseButtonDown = false;
440
+ /**
441
+ * Each mouse button's own state, as the keyboard's sets are for keys: held, pressed since the
442
+ * last poll, claimed this frame, and waiting for somebody to act on it. Bits, by `MOUSE_BIT`.
443
+ *
444
+ * **Beside `mouseButtonDown`, not instead of it.** That flag is "a button is held", which is what
445
+ * an unlocked drag-to-look reads; a game binding fire to the left button and aim to the right
446
+ * needs to know which, and to see a release of one while the other stays down.
447
+ */
448
+ private mouseHeld = 0;
449
+ private mousePressedNow = 0;
450
+ private mousePressedNext = 0;
451
+ private mouseClaimed = 0;
452
+ private mouseUnclaimed = 0;
423
453
  private readonly disposers: Array<() => void> = [];
424
454
 
425
455
  /** Which device the player last actually used, and who wants telling when that changes. */
@@ -531,13 +561,21 @@ export class InputSource {
531
561
  this.keysUnclaimed.clear();
532
562
  });
533
563
 
534
- this.listen(target, 'mousedown', () => {
564
+ this.listen(target, 'mousedown', (e: MouseEvent) => {
535
565
  if (!this.enabled.mouse) return;
536
566
  this.mouseButtonDown = true;
567
+ const bit = mouseBit(e.button);
568
+ this.mouseHeld |= bit;
569
+ this.mousePressedNext |= bit;
570
+ this.mouseUnclaimed |= bit;
537
571
  this.active.use('mouse');
538
572
  });
539
- this.listen(window, 'mouseup', () => {
573
+ /* On the window, so a press that becomes a drag off the canvas still comes back up. */
574
+ this.listen(window, 'mouseup', (e: MouseEvent) => {
540
575
  this.mouseButtonDown = false;
576
+ const bit = mouseBit(e.button);
577
+ this.mouseHeld &= ~bit;
578
+ this.mouseUnclaimed &= ~bit;
541
579
  });
542
580
  /*
543
581
  * **Released by anything that can eat the mouseup, exactly as the keys are.**
@@ -559,15 +597,11 @@ export class InputSource {
559
597
  * `visibilitychange` as well as `blur`, because a tab switched away with a button held
560
598
  * comes back to a document that never saw the release either.
561
599
  */
562
- this.listen(window, 'blur', () => {
563
- this.mouseButtonDown = false;
564
- });
565
- this.listen(window, 'pointercancel', () => {
566
- this.mouseButtonDown = false;
567
- });
600
+ this.listen(window, 'blur', () => this.releaseMouse());
601
+ this.listen(window, 'pointercancel', () => this.releaseMouse());
568
602
  /* Not in `GlobalEventHandlersEventMap`, so it is bound directly and disposed by hand. */
569
603
  const onHidden = (): void => {
570
- if (document.visibilityState !== 'visible') this.mouseButtonDown = false;
604
+ if (document.visibilityState !== 'visible') this.releaseMouse();
571
605
  };
572
606
  document.addEventListener('visibilitychange', onHidden);
573
607
  this.disposers.push(() => document.removeEventListener('visibilitychange', onHidden));
@@ -676,6 +710,10 @@ export class InputSource {
676
710
  spent.clear();
677
711
  this.keysPressedNext = spent;
678
712
  this.keysClaimed.clear();
713
+ /* The mouse buttons' frame boundary, on the same poll and by the same rule. */
714
+ this.mousePressedNow = this.mousePressedNext;
715
+ this.mousePressedNext = 0;
716
+ this.mouseClaimed = 0;
679
717
 
680
718
  if (!this.enabled.gamepad) return;
681
719
  const source = (globalThis as { navigator?: { getGamepads?: () => (RawPad | null)[] } })
@@ -768,6 +806,38 @@ export class InputSource {
768
806
  return true;
769
807
  }
770
808
 
809
+ /**
810
+ * Whether a mouse button is held: pressed over the target and not yet released anywhere.
811
+ *
812
+ * A browser opens its context menu on the right button's release; a game binding that button
813
+ * cancels `contextmenu` on its canvas, which is a decision about the page and not this source's.
814
+ */
815
+ mouseDown(button: MouseButton): boolean {
816
+ return (this.mouseHeld & MOUSE_BIT[button]) !== 0;
817
+ }
818
+
819
+ /** Whether a mouse button went down since the last poll, as `keyPressed` is for a key. */
820
+ mousePressed(button: MouseButton): boolean {
821
+ const bit = MOUSE_BIT[button];
822
+ return (this.mousePressedNow & bit) !== 0 && (this.mouseClaimed & bit) === 0;
823
+ }
824
+
825
+ /** The same edge, claimed, as `consumeKeyPress` is for a key: true for exactly one caller. */
826
+ consumeMousePress(button: MouseButton): boolean {
827
+ const bit = MOUSE_BIT[button];
828
+ if ((this.mouseUnclaimed & bit) === 0) return false;
829
+ this.mouseUnclaimed &= ~bit;
830
+ this.mouseClaimed |= bit;
831
+ return true;
832
+ }
833
+
834
+ /** Every button up and every waiting press gone: what a lost focus or a cancelled pointer means. */
835
+ private releaseMouse(): void {
836
+ this.mouseButtonDown = false;
837
+ this.mouseHeld = 0;
838
+ this.mouseUnclaimed = 0;
839
+ }
840
+
771
841
  /**
772
842
  * Add a device-event subscriber. Multiple reusable control layers may listen
773
843
  * without overwriting one another; interpretation still happens outside the
@@ -96,9 +96,19 @@ export function fakeBrowser(id = 'Xbox 360 Controller (XInput STANDARD GAMEPAD)'
96
96
  const frameCallbacks: ((time: number) => void)[] = [];
97
97
  let nextFrameHandle = 1;
98
98
 
99
+ const targetListeners = new Map<string, ((event: unknown) => void)[]>();
99
100
  const target = {
100
- addEventListener: () => {},
101
- removeEventListener: () => {},
101
+ addEventListener: (type: string, fn: (event: unknown) => void) => {
102
+ const existing = targetListeners.get(type);
103
+ if (existing === undefined) targetListeners.set(type, [fn]);
104
+ else existing.push(fn);
105
+ },
106
+ removeEventListener: (type: string, fn: (event: unknown) => void) => {
107
+ const existing = targetListeners.get(type);
108
+ if (existing === undefined) return;
109
+ const at = existing.indexOf(fn);
110
+ if (at >= 0) existing.splice(at, 1);
111
+ },
102
112
  requestPointerLock: () => undefined,
103
113
  getBoundingClientRect: () => ({ left: 0, top: 0, width: 640, height: 480 }),
104
114
  } as unknown as HTMLElement;
@@ -204,6 +214,21 @@ export function fakeBrowser(id = 'Xbox 360 Controller (XInput STANDARD GAMEPAD)'
204
214
  keyUp(code: string): void {
205
215
  for (const fn of windowListeners.get('keyup') ?? []) fn({ code });
206
216
  },
217
+ /**
218
+ * Press a mouse button over the target, by `MouseEvent.button`: 0 left, 1 middle, 2 right.
219
+ * Pressed on the target and released on the window, as a browser delivers a drag that ends
220
+ * off the canvas.
221
+ */
222
+ mouseDown(button: number): void {
223
+ for (const fn of targetListeners.get('mousedown') ?? []) fn({ button, movementX: 0 });
224
+ },
225
+ mouseUp(button: number): void {
226
+ for (const fn of windowListeners.get('mouseup') ?? []) fn({ button });
227
+ },
228
+ /** The window losing focus, which eats every release that would have followed. */
229
+ blur(): void {
230
+ for (const fn of windowListeners.get('blur') ?? []) fn({});
231
+ },
207
232
  /** Run exactly one animation frame, which is what a poll rides on. */
208
233
  frame(): void {
209
234
  const pending = frameCallbacks.splice(0, frameCallbacks.length);