@forgeax/engine-input 0.1.20 → 0.1.21

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.
@@ -176,7 +176,11 @@ export function attachBrowserInputBackend(
176
176
  const upEdges = new Set<string>();
177
177
  const heldCodes = new Set<string>();
178
178
  const upCodeEdges = new Set<string>();
179
+ const pressedKeys = new Set<string>();
180
+ const pressedCodes = new Set<string>();
179
181
  const buttons: [boolean, boolean, boolean] = [false, false, false];
182
+ const pressedButtons: [boolean, boolean, boolean] = [false, false, false];
183
+ const releasedButtons: [boolean, boolean, boolean] = [false, false, false];
180
184
  let mvx = 0;
181
185
  let mvy = 0;
182
186
  let wheelAccum = 0;
@@ -195,6 +199,15 @@ export function attachBrowserInputBackend(
195
199
  // gameGate is a command-set boolean (setPointerLockAllowed), default true.
196
200
  let gameGate = true;
197
201
 
202
+ function emitLockError(path: 'w3c' | 'provider', cause: unknown): void {
203
+ try {
204
+ options.onLockError?.({ path, cause });
205
+ } catch {
206
+ // Lock diagnostics are observational; a throwing host callback must not
207
+ // create a second unhandled rejection from the browser Promise path.
208
+ }
209
+ }
210
+
198
211
  // w8 (D-1/D-3): release the provider lock idempotently. Calls the injected
199
212
  // exitLock (routing any throw to onLockError as { path: 'provider' }) and
200
213
  // clears providerLocked unconditionally. Shared by every provider-release
@@ -205,7 +218,7 @@ export function attachBrowserInputBackend(
205
218
  try {
206
219
  options.lockProvider.exitLock();
207
220
  } catch (err: unknown) {
208
- if (options.onLockError) options.onLockError({ path: 'provider', cause: err });
221
+ emitLockError('provider', err);
209
222
  }
210
223
  }
211
224
  providerLocked = false;
@@ -348,11 +361,11 @@ export function attachBrowserInputBackend(
348
361
  clear();
349
362
  return;
350
363
  }
364
+ if (!heldKeys.has(ev.key)) pressedKeys.add(ev.key);
351
365
  heldKeys.add(ev.key);
352
- upEdges.delete(ev.key);
353
366
  if (ev.code) {
367
+ if (!heldCodes.has(ev.code)) pressedCodes.add(ev.code);
354
368
  heldCodes.add(ev.code);
355
- upCodeEdges.delete(ev.code);
356
369
  }
357
370
  // w8 (D-1): ESC releases provider lock (W3C path handles ESC via browser
358
371
  // pointerlockchange). Only acts when providerLocked is true.
@@ -379,6 +392,7 @@ export function attachBrowserInputBackend(
379
392
  // Only mouse-type pointers affect the mouse button cluster (D-3).
380
393
  if (ev.pointerType === 'mouse') {
381
394
  if (ev.button === 0 || ev.button === 1 || ev.button === 2) {
395
+ if (!buttons[ev.button]) pressedButtons[ev.button] = true;
382
396
  buttons[ev.button] = true;
383
397
  }
384
398
  }
@@ -453,6 +467,7 @@ export function attachBrowserInputBackend(
453
467
  if (isUiEvent(ev)) return;
454
468
  if (ev.pointerType === 'mouse') {
455
469
  if (ev.button === 0 || ev.button === 1 || ev.button === 2) {
470
+ if (buttons[ev.button]) releasedButtons[ev.button] = true;
456
471
  buttons[ev.button] = false;
457
472
  }
458
473
  }
@@ -547,6 +562,14 @@ export function attachBrowserInputBackend(
547
562
  // sole consumer as it did before ownership routing existed.
548
563
  upEdges.clear();
549
564
  upCodeEdges.clear();
565
+ pressedKeys.clear();
566
+ pressedCodes.clear();
567
+ pressedButtons[0] = false;
568
+ pressedButtons[1] = false;
569
+ pressedButtons[2] = false;
570
+ releasedButtons[0] = false;
571
+ releasedButtons[1] = false;
572
+ releasedButtons[2] = false;
550
573
  heldKeys.clear();
551
574
  heldCodes.clear();
552
575
  buttons[0] = false;
@@ -623,6 +646,11 @@ export function attachBrowserInputBackend(
623
646
  }
624
647
  safeAdd(doc, 'pointerlockchange', onPointerLockChange as EventListener);
625
648
 
649
+ function onPointerLockError(event: Event): void {
650
+ emitLockError('w3c', event);
651
+ }
652
+ safeAdd(doc, 'pointerlockerror', onPointerLockError as EventListener);
653
+
626
654
  // PointerLock entry must be triggered by user activation (W3C requires
627
655
  // a click / keydown handler to call `requestPointerLock()`). The MVP
628
656
  // wires a click listener on the canvas as the activation surface;
@@ -645,9 +673,6 @@ export function attachBrowserInputBackend(
645
673
  // the cursor outside the play-game quadrant) skip the lock entirely. Default
646
674
  // is always-allow, so the standalone game runtime is unchanged.
647
675
  if (options.pointerLockAllowed && !options.pointerLockAllowed()) return;
648
- // Pointer Lock requires window focus; skip silently when unfocused.
649
- if (typeof doc.hasFocus === 'function' && !doc.hasFocus()) return;
650
-
651
676
  // D-2 / D-7: lockProvider path takes priority over W3C.
652
677
  if (options.lockProvider) {
653
678
  providerLocked = true; // D-7 optimistic placement
@@ -656,17 +681,13 @@ export function attachBrowserInputBackend(
656
681
  if (result && typeof (result as Promise<void>).catch === 'function') {
657
682
  (result as Promise<void>).catch((cause: unknown) => {
658
683
  providerLocked = false;
659
- if (options.onLockError) {
660
- options.onLockError({ path: 'provider', cause });
661
- }
684
+ emitLockError('provider', cause);
662
685
  });
663
686
  }
664
687
  } catch (cause: unknown) {
665
688
  // Synchronous throw from requestLock.
666
689
  providerLocked = false;
667
- if (options.onLockError) {
668
- options.onLockError({ path: 'provider', cause });
669
- }
690
+ emitLockError('provider', cause);
670
691
  }
671
692
  return;
672
693
  }
@@ -674,13 +695,15 @@ export function attachBrowserInputBackend(
674
695
  // W3C path: standard requestPointerLock.
675
696
  const fn = canvas.requestPointerLock;
676
697
  if (typeof fn !== 'function') return;
677
- const r = fn.call(canvas) as unknown;
678
- if (r && typeof (r as Promise<void>).catch === 'function') {
679
- (r as Promise<void>).catch((cause: unknown) => {
680
- if (options.onLockError) {
681
- options.onLockError({ path: 'w3c', cause });
682
- }
683
- });
698
+ try {
699
+ const r = fn.call(canvas) as unknown;
700
+ if (r && typeof (r as Promise<void>).catch === 'function') {
701
+ (r as Promise<void>).catch((cause: unknown) => {
702
+ emitLockError('w3c', cause);
703
+ });
704
+ }
705
+ } catch (cause: unknown) {
706
+ emitLockError('w3c', cause);
684
707
  }
685
708
  }
686
709
  safeAdd(canvas, 'click', onCanvasClick as EventListener);
@@ -787,7 +810,19 @@ export function attachBrowserInputBackend(
787
810
  upKeys: new Set(upEdges),
788
811
  downCodes: new Set(heldCodes),
789
812
  upCodes: new Set(upCodeEdges),
813
+ pressedKeys: new Set(pressedKeys),
814
+ pressedCodes: new Set(pressedCodes),
790
815
  buttons: [buttons[0], buttons[1], buttons[2]] as readonly [boolean, boolean, boolean],
816
+ pressedButtons: [pressedButtons[0], pressedButtons[1], pressedButtons[2]] as readonly [
817
+ boolean,
818
+ boolean,
819
+ boolean,
820
+ ],
821
+ releasedButtons: [releasedButtons[0], releasedButtons[1], releasedButtons[2]] as readonly [
822
+ boolean,
823
+ boolean,
824
+ boolean,
825
+ ],
791
826
  movementX: mvx,
792
827
  movementY: mvy,
793
828
  ...(mouseX !== undefined && mouseY !== undefined ? { mouseX, mouseY } : {}),
@@ -806,6 +841,14 @@ export function attachBrowserInputBackend(
806
841
  // Reset per-frame accumulators (movement delta + key edges + wheel notches + phase queue).
807
842
  upEdges.clear();
808
843
  upCodeEdges.clear();
844
+ pressedKeys.clear();
845
+ pressedCodes.clear();
846
+ pressedButtons[0] = false;
847
+ pressedButtons[1] = false;
848
+ pressedButtons[2] = false;
849
+ releasedButtons[0] = false;
850
+ releasedButtons[1] = false;
851
+ releasedButtons[2] = false;
809
852
  mvx = 0;
810
853
  mvy = 0;
811
854
  wheelAccum = 0;
@@ -831,8 +874,10 @@ export function attachBrowserInputBackend(
831
874
  function clear(): void {
832
875
  heldKeys.clear();
833
876
  upEdges.clear();
877
+ pressedKeys.clear();
834
878
  heldCodes.clear();
835
879
  upCodeEdges.clear();
880
+ pressedCodes.clear();
836
881
  pointerMap.clear();
837
882
  phaseQueue.length = 0;
838
883
  prevGamepadFrame.clear();
@@ -841,6 +886,12 @@ export function attachBrowserInputBackend(
841
886
  buttons[0] = false;
842
887
  buttons[1] = false;
843
888
  buttons[2] = false;
889
+ pressedButtons[0] = false;
890
+ pressedButtons[1] = false;
891
+ pressedButtons[2] = false;
892
+ releasedButtons[0] = false;
893
+ releasedButtons[1] = false;
894
+ releasedButtons[2] = false;
844
895
  mvx = 0;
845
896
  mvy = 0;
846
897
  wheelAccum = 0;
@@ -866,6 +917,7 @@ export function attachBrowserInputBackend(
866
917
  safeRemove(canvas, 'wheel', onWheel as EventListener);
867
918
  safeRemove(doc, 'visibilitychange', onVisibilityChange as EventListener);
868
919
  safeRemove(doc, 'pointerlockchange', onPointerLockChange as EventListener);
920
+ safeRemove(doc, 'pointerlockerror', onPointerLockError as EventListener);
869
921
  safeRemove(canvas, 'click', onCanvasClick as EventListener);
870
922
  clear();
871
923
  // D-5: restore original touch-action value.
@@ -59,7 +59,10 @@ export interface CompositeInputBackend extends InputBackend {
59
59
  addMovement(dx: number, dy: number): void;
60
60
  /** Accumulate injected wheel notches (drained on next sample). */
61
61
  addWheel(notches: number): void;
62
- /** Drop all injected state; currently-held keys emit a clean up-edge once. */
62
+ /**
63
+ * Drop all injected state and pending frame edges; currently-held keys/buttons
64
+ * emit one clean release edge so a previously observed hold is closed.
65
+ */
63
66
  clearInjected(): void;
64
67
  /** Toggle the yield-to-human gate at runtime. */
65
68
  setYieldToHuman(yield_: boolean): void;
@@ -76,12 +79,22 @@ export function makeCompositeBackend(
76
79
  const buttons: [boolean, boolean, boolean] = [false, false, false];
77
80
  // Injected per-frame accumulators (drained on each sample()).
78
81
  const upEdges = new Set<string>();
82
+ const pressedKeys = new Set<string>();
83
+ const pressedButtons: [boolean, boolean, boolean] = [false, false, false];
84
+ const releasedButtons: [boolean, boolean, boolean] = [false, false, false];
79
85
  let mvx = 0;
80
86
  let mvy = 0;
81
87
  let wheel = 0;
82
88
 
83
89
  function injectionActive(): boolean {
84
- return heldKeys.size > 0 || upEdges.size > 0 || buttons.some((b) => b);
90
+ return (
91
+ heldKeys.size > 0 ||
92
+ upEdges.size > 0 ||
93
+ pressedKeys.size > 0 ||
94
+ buttons.some((b) => b) ||
95
+ pressedButtons.some((b) => b) ||
96
+ releasedButtons.some((b) => b)
97
+ );
85
98
  }
86
99
 
87
100
  function sample(): InputBackendSample {
@@ -111,12 +124,38 @@ export function makeCompositeBackend(
111
124
  mergedUp.add(k);
112
125
  }
113
126
 
127
+ const mergedPressed =
128
+ base.pressedKeys === undefined && pressedKeys.size === 0
129
+ ? undefined
130
+ : new Set([...(base.pressedKeys ?? []), ...pressedKeys]);
131
+ if (yieldToHuman && base.downKeys.size > 0 && mergedPressed !== undefined) {
132
+ for (const key of pressedKeys) {
133
+ if (base.downKeys.has(key)) mergedPressed.delete(key);
134
+ }
135
+ }
136
+
114
137
  // buttons: OR per slot.
115
138
  const mergedButtons: readonly [boolean, boolean, boolean] = [
116
139
  base.buttons[0] || buttons[0],
117
140
  base.buttons[1] || buttons[1],
118
141
  base.buttons[2] || buttons[2],
119
142
  ];
143
+ const mergedPressedButtons =
144
+ base.pressedButtons === undefined && !pressedButtons.some(Boolean)
145
+ ? undefined
146
+ : ([
147
+ (base.pressedButtons?.[0] ?? false) || pressedButtons[0],
148
+ (base.pressedButtons?.[1] ?? false) || pressedButtons[1],
149
+ (base.pressedButtons?.[2] ?? false) || pressedButtons[2],
150
+ ] as const);
151
+ const mergedReleasedButtons =
152
+ base.releasedButtons === undefined && !releasedButtons.some(Boolean)
153
+ ? undefined
154
+ : ([
155
+ (base.releasedButtons?.[0] ?? false) || releasedButtons[0],
156
+ (base.releasedButtons?.[1] ?? false) || releasedButtons[1],
157
+ (base.releasedButtons?.[2] ?? false) || releasedButtons[2],
158
+ ] as const);
120
159
 
121
160
  // focused: keep true while WE are injecting, so the scan system does not
122
161
  // treat a headless/backgrounded tab (inner.focused === false) as a reason
@@ -127,7 +166,11 @@ export function makeCompositeBackend(
127
166
  ...base, // carry inner optional fields (pointers/gamepads/gestures/...) untouched
128
167
  downKeys,
129
168
  upKeys: mergedUp,
169
+ ...(mergedPressed === undefined ? {} : { pressedKeys: mergedPressed }),
170
+ ...(base.pressedCodes === undefined ? {} : { pressedCodes: base.pressedCodes }),
130
171
  buttons: mergedButtons,
172
+ ...(mergedPressedButtons === undefined ? {} : { pressedButtons: mergedPressedButtons }),
173
+ ...(mergedReleasedButtons === undefined ? {} : { releasedButtons: mergedReleasedButtons }),
131
174
  movementX: base.movementX + mvx,
132
175
  movementY: base.movementY + mvy,
133
176
  wheelDelta: base.wheelDelta + wheel,
@@ -137,6 +180,13 @@ export function makeCompositeBackend(
137
180
 
138
181
  // Drain injected per-frame accumulators (mirrors inner's own drain).
139
182
  upEdges.clear();
183
+ pressedKeys.clear();
184
+ pressedButtons[0] = false;
185
+ pressedButtons[1] = false;
186
+ pressedButtons[2] = false;
187
+ releasedButtons[0] = false;
188
+ releasedButtons[1] = false;
189
+ releasedButtons[2] = false;
140
190
  mvx = 0;
141
191
  mvy = 0;
142
192
  wheel = 0;
@@ -158,13 +208,15 @@ export function makeCompositeBackend(
158
208
  detach: () => inner.detach(),
159
209
 
160
210
  press(key) {
211
+ if (!heldKeys.has(key)) pressedKeys.add(key);
161
212
  heldKeys.add(key);
162
- upEdges.delete(key); // re-press cancels a pending release edge
163
213
  },
164
214
  release(key) {
165
215
  if (heldKeys.delete(key)) upEdges.add(key); // held -> emit one up-edge
166
216
  },
167
217
  setButton(slot, down) {
218
+ if (down && !buttons[slot]) pressedButtons[slot] = true;
219
+ if (!down && buttons[slot]) releasedButtons[slot] = true;
168
220
  buttons[slot] = down;
169
221
  },
170
222
  addMovement(dx, dy) {
@@ -175,8 +227,23 @@ export function makeCompositeBackend(
175
227
  wheel += notches;
176
228
  },
177
229
  clearInjected() {
230
+ // Clearing is a lease boundary: discard every pending frame edge first,
231
+ // then emit only the releases required to close state that was held
232
+ // across the boundary. This prevents a press/release queued before a
233
+ // revoke from becoming a ghost edge for the next consumer.
234
+ upEdges.clear();
235
+ pressedKeys.clear();
178
236
  for (const k of heldKeys) upEdges.add(k); // clean release edge for each
179
237
  heldKeys.clear();
238
+ pressedButtons[0] = false;
239
+ pressedButtons[1] = false;
240
+ pressedButtons[2] = false;
241
+ releasedButtons[0] = false;
242
+ releasedButtons[1] = false;
243
+ releasedButtons[2] = false;
244
+ if (buttons[0]) releasedButtons[0] = true;
245
+ if (buttons[1]) releasedButtons[1] = true;
246
+ if (buttons[2]) releasedButtons[2] = true;
180
247
  buttons[0] = buttons[1] = buttons[2] = false;
181
248
  mvx = 0;
182
249
  mvy = 0;
@@ -50,8 +50,8 @@ export const INPUT_BACKEND_KEY = 'InputBackend' as const;
50
50
  * Module-level `defineSystem` with the real fn body — no factory, no closure.
51
51
  * Each `world.update()` tick:
52
52
  * 1. reads the {@link InputBackend} from `INPUT_BACKEND_KEY` and calls
53
- * `backend.sample()` to drain the per-frame accumulator (movement delta +
54
- * up-edge set);
53
+ * `backend.sample()` once to atomically drain the per-frame accumulators
54
+ * (movement delta plus key/code/button press and release edge sets);
55
55
  * 2. derives a fresh `InputSnapshot` via `snapshotFromSample`;
56
56
  * 3. writes it under `INPUT_SNAPSHOT_RESOURCE_KEY` via
57
57
  * `world.insertResource` -- idempotent overwrite, charter P4
@@ -349,12 +349,14 @@ export interface InputSnapshot {
349
349
  export interface InputBackend {
350
350
  /**
351
351
  * Produce one frame's worth of input data and reset the per-frame
352
- * accumulators (movement delta + up-edge set). Held-key state and
353
- * held-button state survive across calls.
352
+ * accumulators (movement delta + edge sets). Held-key state and
353
+ * held-button state survive across calls. Browser producers latch edge
354
+ * transitions when events arrive, so a down+up sequence between two scans
355
+ * remains observable instead of being lost to the held-state projection.
354
356
  *
355
357
  * Contract notes:
356
358
  * - `downKeys` is the held-key set as of the call (held across frames)
357
- * - `upKeys` is the up-edge set since the previous sample (cleared
359
+ * - `upKeys` is the release-edge set since the previous sample (cleared
358
360
  * after the call so the edge appears in exactly one frame)
359
361
  * - `buttons` is the held-button slot tuple `[b0, b1, b2]`
360
362
  * - `movementX` / `movementY` are the accumulated PointerLock delta
@@ -392,7 +394,15 @@ export interface InputBackendSample {
392
394
  readonly downCodes?: ReadonlySet<string>;
393
395
  /** One-frame physical code release edges, when the producer has them. */
394
396
  readonly upCodes?: ReadonlySet<string>;
397
+ /** Logical key press edges latched since the previous frame-start scan. */
398
+ readonly pressedKeys?: ReadonlySet<string>;
399
+ /** Physical code press edges latched since the previous frame-start scan. */
400
+ readonly pressedCodes?: ReadonlySet<string>;
395
401
  readonly buttons: readonly [boolean, boolean, boolean];
402
+ /** Mouse button press edges latched since the previous frame-start scan. */
403
+ readonly pressedButtons?: readonly [boolean, boolean, boolean];
404
+ /** Mouse button release edges latched since the previous frame-start scan. */
405
+ readonly releasedButtons?: readonly [boolean, boolean, boolean];
396
406
  readonly movementX: number;
397
407
  readonly movementY: number;
398
408
  /** Latest canvas-pixel mouse position; absent for non-pointer backends. */
@@ -530,35 +540,45 @@ export function snapshotFromSample(
530
540
  // a derived view of the producer's state at one instant.
531
541
  const heldKeys = new Set<string>(sample.downKeys);
532
542
  const upEdges = new Set<string>(sample.upKeys);
533
- const justPressedKeys = new Set<string>();
534
- for (const key of heldKeys) {
535
- if (previousSnapshot === undefined || !previousSnapshot.keyboard.down(key)) {
536
- justPressedKeys.add(key);
537
- }
538
- }
543
+ const justPressedKeys =
544
+ sample.pressedKeys === undefined
545
+ ? new Set<string>(
546
+ [...heldKeys].filter(
547
+ (key) => previousSnapshot === undefined || !previousSnapshot.keyboard.down(key),
548
+ ),
549
+ )
550
+ : new Set<string>(sample.pressedKeys);
539
551
  const heldCodes = new Set<string>(sample.downCodes ?? []);
540
552
  const upCodeEdges = new Set<string>(sample.upCodes ?? []);
541
- const justPressedCodes = new Set<string>();
542
- for (const code of heldCodes) {
543
- if (previousSnapshot === undefined || !previousSnapshot.keyboard.downCode(code)) {
544
- justPressedCodes.add(code);
545
- }
546
- }
553
+ const justPressedCodes =
554
+ sample.pressedCodes === undefined
555
+ ? new Set<string>(
556
+ [...heldCodes].filter(
557
+ (code) => previousSnapshot === undefined || !previousSnapshot.keyboard.downCode(code),
558
+ ),
559
+ )
560
+ : new Set<string>(sample.pressedCodes);
547
561
  const buttons: readonly [boolean, boolean, boolean] = [
548
562
  sample.buttons[0],
549
563
  sample.buttons[1],
550
564
  sample.buttons[2],
551
565
  ];
552
- const justPressedButtons: readonly [boolean, boolean, boolean] = [
553
- buttons[0] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(0)),
554
- buttons[1] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(1)),
555
- buttons[2] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(2)),
556
- ];
557
- const justReleasedButtons: readonly [boolean, boolean, boolean] = [
558
- sample.focusReset !== true && previousSnapshot?.mouse.button(0) === true && !buttons[0],
559
- sample.focusReset !== true && previousSnapshot?.mouse.button(1) === true && !buttons[1],
560
- sample.focusReset !== true && previousSnapshot?.mouse.button(2) === true && !buttons[2],
561
- ];
566
+ const justPressedButtons: readonly [boolean, boolean, boolean] =
567
+ sample.pressedButtons === undefined
568
+ ? [
569
+ buttons[0] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(0)),
570
+ buttons[1] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(1)),
571
+ buttons[2] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(2)),
572
+ ]
573
+ : [sample.pressedButtons[0], sample.pressedButtons[1], sample.pressedButtons[2]];
574
+ const justReleasedButtons: readonly [boolean, boolean, boolean] =
575
+ sample.releasedButtons === undefined
576
+ ? [
577
+ sample.focusReset !== true && previousSnapshot?.mouse.button(0) === true && !buttons[0],
578
+ sample.focusReset !== true && previousSnapshot?.mouse.button(1) === true && !buttons[1],
579
+ sample.focusReset !== true && previousSnapshot?.mouse.button(2) === true && !buttons[2],
580
+ ]
581
+ : [sample.releasedButtons[0], sample.releasedButtons[1], sample.releasedButtons[2]];
562
582
  const movementDelta = Object.freeze({ x: sample.movementX, y: sample.movementY });
563
583
  const mousePosition =
564
584
  sample.mouseX === undefined || sample.mouseY === undefined