@forgeax/engine-input 0.1.31 → 0.1.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-input",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -31,8 +31,8 @@
31
31
  "LICENSE"
32
32
  ],
33
33
  "dependencies": {
34
- "@forgeax/engine-ecs": "0.1.31",
35
- "@forgeax/engine-plugin": "0.1.31"
34
+ "@forgeax/engine-ecs": "0.1.33",
35
+ "@forgeax/engine-plugin": "0.1.33"
36
36
  },
37
37
  "forgeax": {
38
38
  "metrics": {
@@ -92,6 +92,24 @@ describe('CompositeInputBackend merge semantics', () => {
92
92
  expect(c.sample().downKeys.has('w')).toBe(true); // still held next frame
93
93
  });
94
94
 
95
+ it('accepts logical keys and physical codes for both keyboard projections', () => {
96
+ const inner = fakeInner();
97
+ const c = makeCompositeBackend(inner);
98
+ c.press('w');
99
+ let sample = c.sample();
100
+ expect(sample.downKeys.has('w')).toBe(true);
101
+ expect(sample.downCodes?.has('KeyW')).toBe(true);
102
+ c.release('w');
103
+ sample = c.sample();
104
+ expect(sample.upKeys.has('w')).toBe(true);
105
+ expect(sample.upCodes?.has('KeyW')).toBe(true);
106
+
107
+ c.press('KeyW');
108
+ sample = c.sample();
109
+ expect(sample.downKeys.has('w')).toBe(true);
110
+ expect(sample.downCodes?.has('KeyW')).toBe(true);
111
+ });
112
+
95
113
  it('release produces an up-edge that lives exactly ONE frame', () => {
96
114
  const inner = fakeInner();
97
115
  const c = makeCompositeBackend(inner);
@@ -275,6 +293,43 @@ describe('CompositeInputBackend merge semantics', () => {
275
293
  expect(sample.releasedButtons).toBeUndefined();
276
294
  });
277
295
 
296
+ it('revokeInjectedLease fences late injections until a new lease opens', () => {
297
+ const inner = fakeInner();
298
+ const c = makeCompositeBackend(inner);
299
+ c.press('w');
300
+ expect(c.sample().downCodes?.has('KeyW')).toBe(true);
301
+ c.revokeInjectedLease();
302
+ c.press('a');
303
+ expect(c.sample().downKeys.has('a')).toBe(false);
304
+ c.beginInjectedLease();
305
+ c.press('a');
306
+ expect(c.sample().downCodes?.has('KeyA')).toBe(true);
307
+ });
308
+
309
+ it('keeps an old execution lease read-only after a later lease opens', () => {
310
+ const inner = fakeInner();
311
+ const c = makeCompositeBackend(inner);
312
+ const oldLease = c.createInjectedLease();
313
+ oldLease.press('KeyW');
314
+ expect(c.sample().downCodes?.has('KeyW')).toBe(true);
315
+
316
+ const currentLease = c.createInjectedLease();
317
+ oldLease.revokeInjectedLease();
318
+ oldLease.press('KeyA');
319
+ oldLease.setButton(0, true);
320
+ oldLease.addMovement(10, 20);
321
+ oldLease.addWheel(3);
322
+ expect(c.sample().downKeys.has('a')).toBe(false);
323
+ expect(c.sample().buttons[0]).toBe(false);
324
+ expect(c.sample().movementX).toBe(0);
325
+ expect(c.sample().wheelDelta).toBe(0);
326
+
327
+ currentLease.press('KeyD');
328
+ const sample = c.sample();
329
+ expect(sample.downKeys.has('d')).toBe(true);
330
+ expect(sample.downKeys.has('a')).toBe(false);
331
+ });
332
+
278
333
  it('forwards setPointerLockAllowed and detach to inner', () => {
279
334
  let allowed: boolean | undefined;
280
335
  let detached = false;
@@ -64,22 +64,113 @@ export interface CompositeInputBackend extends InputBackend {
64
64
  * emit one clean release edge so a previously observed hold is closed.
65
65
  */
66
66
  clearInjected(): void;
67
+ /** Open a fresh synthetic-input lease after a previous revoke boundary. */
68
+ beginInjectedLease(): void;
69
+ /** Revoke the current lease and fence later injections until reopened. */
70
+ revokeInjectedLease(): void;
71
+ /**
72
+ * Create the one execution-scoped injection view. Creating a lease
73
+ * invalidates every previously-created view; an invalid view remains a
74
+ * no-op even after a later lease is opened.
75
+ */
76
+ createInjectedLease(): CompositeInputLease;
67
77
  /** Toggle the yield-to-human gate at runtime. */
68
78
  setYieldToHuman(yield_: boolean): void;
69
79
  }
70
80
 
81
+ /**
82
+ * Synthetic input exposed to one eval/execution. It deliberately has no
83
+ * `beginInjectedLease` method: a script can release its own lease, but it
84
+ * cannot reopen an old reference after the host has moved to another job.
85
+ */
86
+ export interface CompositeInputLease extends InputBackend {
87
+ press(key: string): void;
88
+ release(key: string): void;
89
+ setButton(slot: 0 | 1 | 2, down: boolean): void;
90
+ addMovement(dx: number, dy: number): void;
91
+ addWheel(notches: number): void;
92
+ clearInjected(): void;
93
+ revokeInjectedLease(): void;
94
+ }
95
+
96
+ // The public injection surface accepts either the logical KeyboardEvent.key
97
+ // (`w`, ` `, `ArrowUp`) or the physical KeyboardEvent.code (`KeyW`, `Space`).
98
+ // Keep both projections populated so code-first control drives games that read
99
+ // either keyboard.down(...) or keyboard.downCode(...).
100
+ const CODE_TO_KEY: Readonly<Record<string, string>> = {
101
+ Space: ' ',
102
+ ShiftLeft: 'Shift',
103
+ ShiftRight: 'Shift',
104
+ ControlLeft: 'Control',
105
+ ControlRight: 'Control',
106
+ AltLeft: 'Alt',
107
+ AltRight: 'Alt',
108
+ MetaLeft: 'Meta',
109
+ MetaRight: 'Meta',
110
+ BracketLeft: '[',
111
+ BracketRight: ']',
112
+ Backslash: '\\',
113
+ Semicolon: ';',
114
+ Quote: "'",
115
+ Comma: ',',
116
+ Period: '.',
117
+ Slash: '/',
118
+ Minus: '-',
119
+ Equal: '=',
120
+ Backquote: '`',
121
+ };
122
+
123
+ function keyForCode(code: string): string | undefined {
124
+ if (CODE_TO_KEY[code] !== undefined) return CODE_TO_KEY[code];
125
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
126
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
127
+ if (/^Numpad[0-9]$/.test(code)) return code.slice(6);
128
+ if (/^F(?:[1-9]|1[0-2])$/.test(code)) return code;
129
+ if (/^Arrow(?:Up|Down|Left|Right)$/.test(code)) return code;
130
+ if (/^(?:Escape|Enter|Tab|Backspace|Delete|Home|End|PageUp|PageDown|Insert)$/.test(code))
131
+ return code;
132
+ return undefined;
133
+ }
134
+
135
+ function codeForKey(key: string): string | undefined {
136
+ if (/^[a-zA-Z]$/.test(key)) return `Key${key.toUpperCase()}`;
137
+ if (/^[0-9]$/.test(key)) return `Digit${key}`;
138
+ if (key === ' ') return 'Space';
139
+ if (
140
+ /^(?:Arrow(?:Up|Down|Left|Right)|Escape|Enter|Tab|Backspace|Delete|Home|End|PageUp|PageDown|Insert)$/.test(
141
+ key,
142
+ )
143
+ )
144
+ return key;
145
+ if (/^F(?:[1-9]|1[0-2])$/.test(key)) return key;
146
+ const entry = Object.entries(CODE_TO_KEY).find(([, value]) => value === key);
147
+ return entry?.[0];
148
+ }
149
+
150
+ function normalizeKey(value: string): { readonly key: string; readonly code: string | undefined } {
151
+ const logical = keyForCode(value);
152
+ return logical === undefined
153
+ ? { key: value, code: codeForKey(value) }
154
+ : { key: logical, code: value };
155
+ }
156
+
71
157
  export function makeCompositeBackend(
72
158
  inner: InputBackend,
73
159
  options?: CompositeBackendOptions,
74
160
  ): CompositeInputBackend {
75
161
  let yieldToHuman = options?.yieldToHuman ?? true;
162
+ let leaseRevoked = false;
163
+ let leaseGeneration = 0;
76
164
 
77
165
  // Injected held-state (survives across frames).
78
166
  const heldKeys = new Set<string>();
167
+ const heldCodes = new Set<string>();
79
168
  const buttons: [boolean, boolean, boolean] = [false, false, false];
80
169
  // Injected per-frame accumulators (drained on each sample()).
81
170
  const upEdges = new Set<string>();
171
+ const upCodeEdges = new Set<string>();
82
172
  const pressedKeys = new Set<string>();
173
+ const pressedCodes = new Set<string>();
83
174
  const pressedButtons: [boolean, boolean, boolean] = [false, false, false];
84
175
  const releasedButtons: [boolean, boolean, boolean] = [false, false, false];
85
176
  let mvx = 0;
@@ -89,8 +180,11 @@ export function makeCompositeBackend(
89
180
  function injectionActive(): boolean {
90
181
  return (
91
182
  heldKeys.size > 0 ||
183
+ heldCodes.size > 0 ||
92
184
  upEdges.size > 0 ||
185
+ upCodeEdges.size > 0 ||
93
186
  pressedKeys.size > 0 ||
187
+ pressedCodes.size > 0 ||
94
188
  buttons.some((b) => b) ||
95
189
  pressedButtons.some((b) => b) ||
96
190
  releasedButtons.some((b) => b)
@@ -111,26 +205,79 @@ export function makeCompositeBackend(
111
205
  // is also pressing. A yielded injected release is likewise a no-op: emitting its
112
206
  // up-edge would incorrectly release the human-held key in the scan system.
113
207
  const downKeys = new Set(base.downKeys);
208
+ const downCodes =
209
+ base.downCodes === undefined && heldCodes.size === 0
210
+ ? undefined
211
+ : new Set(base.downCodes ?? []);
114
212
  for (const k of heldKeys) {
115
- if (yieldToHuman && base.downKeys.has(k)) continue; // human owns this exact key
213
+ const code = codeForKey(k);
214
+ if (
215
+ yieldToHuman &&
216
+ (base.downKeys.has(k) || (code !== undefined && base.downCodes?.has(code) === true))
217
+ )
218
+ continue; // human owns this exact key
116
219
  downKeys.add(k);
117
220
  }
221
+ for (const code of heldCodes) {
222
+ const key = keyForCode(code);
223
+ if (
224
+ yieldToHuman &&
225
+ (base.downCodes?.has(code) === true || (key !== undefined && base.downKeys.has(key)))
226
+ )
227
+ continue;
228
+ downCodes?.add(code);
229
+ }
118
230
 
119
231
  // upKeys: union of human up-edges and injected up-edges (one-frame life), except
120
232
  // injected edges for keys that the human still owns under the per-key yield gate.
121
233
  const mergedUp = new Set(base.upKeys);
122
234
  for (const k of upEdges) {
123
- if (yieldToHuman && base.downKeys.has(k)) continue;
235
+ const code = codeForKey(k);
236
+ if (
237
+ yieldToHuman &&
238
+ (base.downKeys.has(k) || (code !== undefined && base.downCodes?.has(code) === true))
239
+ )
240
+ continue;
124
241
  mergedUp.add(k);
125
242
  }
243
+ const mergedUpCodes =
244
+ base.upCodes === undefined && upCodeEdges.size === 0
245
+ ? undefined
246
+ : new Set(base.upCodes ?? []);
247
+ for (const code of upCodeEdges) {
248
+ const key = keyForCode(code);
249
+ if (
250
+ yieldToHuman &&
251
+ (base.downCodes?.has(code) === true || (key !== undefined && base.downKeys.has(key)))
252
+ )
253
+ continue;
254
+ mergedUpCodes?.add(code);
255
+ }
126
256
 
127
257
  const mergedPressed =
128
258
  base.pressedKeys === undefined && pressedKeys.size === 0
129
259
  ? undefined
130
260
  : new Set([...(base.pressedKeys ?? []), ...pressedKeys]);
131
- if (yieldToHuman && base.downKeys.size > 0 && mergedPressed !== undefined) {
261
+ if (
262
+ yieldToHuman &&
263
+ (base.downKeys.size > 0 || (base.downCodes?.size ?? 0) > 0) &&
264
+ mergedPressed !== undefined
265
+ ) {
132
266
  for (const key of pressedKeys) {
133
- if (base.downKeys.has(key)) mergedPressed.delete(key);
267
+ const code = codeForKey(key);
268
+ if (base.downKeys.has(key) || (code !== undefined && base.downCodes?.has(code) === true))
269
+ mergedPressed.delete(key);
270
+ }
271
+ }
272
+ const mergedPressedCodes =
273
+ base.pressedCodes === undefined && pressedCodes.size === 0
274
+ ? undefined
275
+ : new Set(base.pressedCodes ?? []);
276
+ if (yieldToHuman && mergedPressedCodes !== undefined) {
277
+ for (const code of pressedCodes) {
278
+ const key = keyForCode(code);
279
+ if (base.downCodes?.has(code) === true || (key !== undefined && base.downKeys.has(key)))
280
+ mergedPressedCodes.delete(code);
134
281
  }
135
282
  }
136
283
 
@@ -166,8 +313,10 @@ export function makeCompositeBackend(
166
313
  ...base, // carry inner optional fields (pointers/gamepads/gestures/...) untouched
167
314
  downKeys,
168
315
  upKeys: mergedUp,
316
+ ...(downCodes === undefined ? {} : { downCodes }),
317
+ ...(mergedUpCodes === undefined ? {} : { upCodes: mergedUpCodes }),
169
318
  ...(mergedPressed === undefined ? {} : { pressedKeys: mergedPressed }),
170
- ...(base.pressedCodes === undefined ? {} : { pressedCodes: base.pressedCodes }),
319
+ ...(mergedPressedCodes === undefined ? {} : { pressedCodes: mergedPressedCodes }),
171
320
  buttons: mergedButtons,
172
321
  ...(mergedPressedButtons === undefined ? {} : { pressedButtons: mergedPressedButtons }),
173
322
  ...(mergedReleasedButtons === undefined ? {} : { releasedButtons: mergedReleasedButtons }),
@@ -180,7 +329,9 @@ export function makeCompositeBackend(
180
329
 
181
330
  // Drain injected per-frame accumulators (mirrors inner's own drain).
182
331
  upEdges.clear();
332
+ upCodeEdges.clear();
183
333
  pressedKeys.clear();
334
+ pressedCodes.clear();
184
335
  pressedButtons[0] = false;
185
336
  pressedButtons[1] = false;
186
337
  pressedButtons[2] = false;
@@ -201,54 +352,146 @@ export function makeCompositeBackend(
201
352
  ? { setPointerLockAllowed: (allowed: boolean) => inner.setPointerLockAllowed?.(allowed) }
202
353
  : {};
203
354
 
355
+ const clearInjected = (): void => {
356
+ // Clearing is a lease boundary: discard every pending frame edge first,
357
+ // then emit only the releases required to close state that was held
358
+ // across the boundary. This prevents a press/release queued before a
359
+ // revoke from becoming a ghost edge for the next consumer.
360
+ upEdges.clear();
361
+ upCodeEdges.clear();
362
+ pressedKeys.clear();
363
+ pressedCodes.clear();
364
+ for (const k of heldKeys) upEdges.add(k);
365
+ for (const code of heldCodes) upCodeEdges.add(code);
366
+ heldKeys.clear();
367
+ heldCodes.clear();
368
+ pressedButtons[0] = false;
369
+ pressedButtons[1] = false;
370
+ pressedButtons[2] = false;
371
+ releasedButtons[0] = false;
372
+ releasedButtons[1] = false;
373
+ releasedButtons[2] = false;
374
+ if (buttons[0]) releasedButtons[0] = true;
375
+ if (buttons[1]) releasedButtons[1] = true;
376
+ if (buttons[2]) releasedButtons[2] = true;
377
+ buttons[0] = buttons[1] = buttons[2] = false;
378
+ mvx = 0;
379
+ mvy = 0;
380
+ wheel = 0;
381
+ };
382
+
383
+ const beginInjectedLease = (): void => {
384
+ // A new boundary must invalidate old references. Merely flipping a global
385
+ // boolean would let an async script keep writing after a later job opens.
386
+ leaseGeneration += 1;
387
+ leaseRevoked = false;
388
+ clearInjected();
389
+ };
390
+ const revokeInjectedLease = (): void => {
391
+ leaseGeneration += 1;
392
+ leaseRevoked = true;
393
+ clearInjected();
394
+ };
395
+
396
+ const createInjectedLease = (): CompositeInputLease => {
397
+ beginInjectedLease();
398
+ const generation = leaseGeneration;
399
+ const active = (): boolean => !leaseRevoked && generation === leaseGeneration;
400
+ return {
401
+ sample,
402
+ detach: () => {},
403
+ press(key) {
404
+ if (!active()) return;
405
+ const normalized = normalizeKey(key);
406
+ if (!heldKeys.has(normalized.key)) pressedKeys.add(normalized.key);
407
+ heldKeys.add(normalized.key);
408
+ if (normalized.code !== undefined) {
409
+ if (!heldCodes.has(normalized.code)) pressedCodes.add(normalized.code);
410
+ heldCodes.add(normalized.code);
411
+ }
412
+ },
413
+ release(key) {
414
+ if (!active()) return;
415
+ const normalized = normalizeKey(key);
416
+ if (heldKeys.delete(normalized.key)) upEdges.add(normalized.key);
417
+ if (normalized.code !== undefined && heldCodes.delete(normalized.code))
418
+ upCodeEdges.add(normalized.code);
419
+ },
420
+ setButton(slot, down) {
421
+ if (!active()) return;
422
+ if (down && !buttons[slot]) pressedButtons[slot] = true;
423
+ if (!down && buttons[slot]) releasedButtons[slot] = true;
424
+ buttons[slot] = down;
425
+ },
426
+ addMovement(dx, dy) {
427
+ if (!active()) return;
428
+ mvx += dx;
429
+ mvy += dy;
430
+ },
431
+ addWheel(notches) {
432
+ if (!active()) return;
433
+ wheel += notches;
434
+ },
435
+ clearInjected() {
436
+ if (active()) clearInjected();
437
+ },
438
+ revokeInjectedLease() {
439
+ if (active()) revokeInjectedLease();
440
+ },
441
+ };
442
+ };
443
+
204
444
  return {
205
445
  sample,
206
446
  ...lockGate,
207
- // Teardown belongs to the human backend -- forward it.
208
- detach: () => inner.detach(),
447
+ // Teardown belongs to the human backend -- revoke the synthetic lease
448
+ // first, then forward the physical listener teardown. `clear()` is also
449
+ // exposed so an owner can revoke input without detaching the browser.
450
+ clear: () => {
451
+ clearInjected();
452
+ inner.clear?.();
453
+ },
454
+ detach: () => {
455
+ clearInjected();
456
+ inner.detach();
457
+ },
209
458
 
210
459
  press(key) {
211
- if (!heldKeys.has(key)) pressedKeys.add(key);
212
- heldKeys.add(key);
460
+ if (leaseRevoked) return;
461
+ const normalized = normalizeKey(key);
462
+ if (!heldKeys.has(normalized.key)) pressedKeys.add(normalized.key);
463
+ heldKeys.add(normalized.key);
464
+ if (normalized.code !== undefined) {
465
+ if (!heldCodes.has(normalized.code)) pressedCodes.add(normalized.code);
466
+ heldCodes.add(normalized.code);
467
+ }
213
468
  },
214
469
  release(key) {
215
- if (heldKeys.delete(key)) upEdges.add(key); // held -> emit one up-edge
470
+ if (leaseRevoked) return;
471
+ const normalized = normalizeKey(key);
472
+ if (heldKeys.delete(normalized.key)) upEdges.add(normalized.key); // held -> emit one up-edge
473
+ if (normalized.code !== undefined && heldCodes.delete(normalized.code))
474
+ upCodeEdges.add(normalized.code);
216
475
  },
217
476
  setButton(slot, down) {
477
+ if (leaseRevoked) return;
218
478
  if (down && !buttons[slot]) pressedButtons[slot] = true;
219
479
  if (!down && buttons[slot]) releasedButtons[slot] = true;
220
480
  buttons[slot] = down;
221
481
  },
222
482
  addMovement(dx, dy) {
483
+ if (leaseRevoked) return;
223
484
  mvx += dx;
224
485
  mvy += dy;
225
486
  },
226
487
  addWheel(notches) {
488
+ if (leaseRevoked) return;
227
489
  wheel += notches;
228
490
  },
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();
236
- for (const k of heldKeys) upEdges.add(k); // clean release edge for each
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;
247
- buttons[0] = buttons[1] = buttons[2] = false;
248
- mvx = 0;
249
- mvy = 0;
250
- wheel = 0;
251
- },
491
+ clearInjected,
492
+ beginInjectedLease,
493
+ revokeInjectedLease,
494
+ createInjectedLease,
252
495
  setYieldToHuman(yield_) {
253
496
  yieldToHuman = yield_;
254
497
  },
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  export {
49
49
  type CompositeBackendOptions,
50
50
  type CompositeInputBackend,
51
+ type CompositeInputLease,
51
52
  makeCompositeBackend,
52
53
  } from './composite-backend';
53
54
  export {