@caperjs/core 0.3.0 → 0.4.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 (83) hide show
  1. package/build/assetpack.mjs +90 -0
  2. package/build/plugins/pruneFallbacks.mjs +25 -2
  3. package/lib/{CaptionsPlugin-BLbLl3WB.js → CaptionsPlugin-cSJj-R9M.js} +4 -4
  4. package/lib/{CaptionsPlugin-BLbLl3WB.js.map → CaptionsPlugin-cSJj-R9M.js.map} +1 -1
  5. package/lib/{DataAdapter-aHSNM_26.js → DataAdapter-D2tc6hx9.js} +5 -5
  6. package/lib/{DataAdapter-aHSNM_26.js.map → DataAdapter-D2tc6hx9.js.map} +1 -1
  7. package/lib/{DebugRenderer-8LjHtBzA.js → DebugRenderer-CGUKy6Fy.js} +2 -2
  8. package/lib/{DebugRenderer-8LjHtBzA.js.map → DebugRenderer-CGUKy6Fy.js.map} +1 -1
  9. package/lib/{DevToolsPlugin-Vrk6aOtS.js → DevToolsPlugin-B8GX3H9b.js} +2 -2
  10. package/lib/{DevToolsPlugin-Vrk6aOtS.js.map → DevToolsPlugin-B8GX3H9b.js.map} +1 -1
  11. package/lib/{GSAPPlugin-Hm3F4zdg.js → GSAPPlugin-Br9AjuTD.js} +5 -5
  12. package/lib/{GSAPPlugin-Hm3F4zdg.js.map → GSAPPlugin-Br9AjuTD.js.map} +1 -1
  13. package/lib/{LayoutPlugin-Dpgeawfw.js → LayoutPlugin-BX3OP7pw.js} +4 -4
  14. package/lib/{LayoutPlugin-Dpgeawfw.js.map → LayoutPlugin-BX3OP7pw.js.map} +1 -1
  15. package/lib/{SpinePlugin-B08yAw1S.js → SpinePlugin-C-JDdXpf.js} +2 -2
  16. package/lib/{SpinePlugin-B08yAw1S.js.map → SpinePlugin-C-JDdXpf.js.map} +1 -1
  17. package/lib/{StatsPlugin-D2Xa5j97.js → StatsPlugin-yJlJtoQ2.js} +7 -10
  18. package/lib/{StatsPlugin-D2Xa5j97.js.map → StatsPlugin-yJlJtoQ2.js.map} +1 -1
  19. package/lib/{VoiceOverPlugin-qtFlqpRn.js → VoiceOverPlugin-DjSZp4Ko.js} +4 -4
  20. package/lib/{VoiceOverPlugin-qtFlqpRn.js.map → VoiceOverPlugin-DjSZp4Ko.js.map} +1 -1
  21. package/lib/caper.mjs +156 -1927
  22. package/lib/caper.mjs.map +1 -1
  23. package/lib/const-C3sxeYhl.js +1746 -0
  24. package/lib/const-C3sxeYhl.js.map +1 -0
  25. package/lib/core/create.d.ts.map +1 -1
  26. package/lib/padding-1kHDpGlQ.js +41 -0
  27. package/lib/padding-1kHDpGlQ.js.map +1 -0
  28. package/lib/plugins/FullScreenPlugin.d.ts +0 -5
  29. package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
  30. package/lib/plugins/KeyboardPlugin.d.ts +0 -1
  31. package/lib/plugins/KeyboardPlugin.d.ts.map +1 -1
  32. package/lib/plugins/Plugin.d.ts +101 -2
  33. package/lib/plugins/Plugin.d.ts.map +1 -1
  34. package/lib/plugins/SceneManagerPlugin.d.ts +0 -1
  35. package/lib/plugins/SceneManagerPlugin.d.ts.map +1 -1
  36. package/lib/plugins/StatsPlugin.d.ts +0 -1
  37. package/lib/plugins/StatsPlugin.d.ts.map +1 -1
  38. package/lib/plugins/TimerPlugin.d.ts.map +1 -1
  39. package/lib/plugins/WebEventsPlugin.d.ts +0 -1
  40. package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
  41. package/lib/plugins/breakpoints/BreakpointPlugin.d.ts.map +1 -1
  42. package/lib/plugins/gesture/GesturePlugin.d.ts.map +1 -1
  43. package/lib/plugins/input/AbstractControls.d.ts +46 -7
  44. package/lib/plugins/input/AbstractControls.d.ts.map +1 -1
  45. package/lib/plugins/input/InputPlugin.d.ts.map +1 -1
  46. package/lib/plugins/input/controlsCore.d.ts +58 -0
  47. package/lib/plugins/input/controlsCore.d.ts.map +1 -0
  48. package/lib/plugins/input/controlsCore.test.d.ts +2 -0
  49. package/lib/plugins/input/controlsCore.test.d.ts.map +1 -0
  50. package/lib/plugins/input/keyboard/KeyboardControls.d.ts +15 -0
  51. package/lib/plugins/input/keyboard/KeyboardControls.d.ts.map +1 -0
  52. package/lib/plugins/input/keyboard/index.d.ts +1 -1
  53. package/lib/plugins/input/keyboard/index.d.ts.map +1 -1
  54. package/lib/plugins/input/touch/VirtualControls.d.ts +7 -19
  55. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
  56. package/lib/{registries-m2vKTIih.js → registries-BfV-VF7a.js} +565 -552
  57. package/lib/registries-BfV-VF7a.js.map +1 -0
  58. package/package.json +1 -1
  59. package/src/core/create.ts +11 -0
  60. package/src/plugins/FullScreenPlugin.ts +5 -18
  61. package/src/plugins/KeyboardPlugin.ts +3 -10
  62. package/src/plugins/Plugin.test.ts +130 -2
  63. package/src/plugins/Plugin.ts +159 -3
  64. package/src/plugins/SceneManagerPlugin.ts +3 -8
  65. package/src/plugins/StatsPlugin.ts +3 -10
  66. package/src/plugins/TimerPlugin.ts +1 -2
  67. package/src/plugins/WebEventsPlugin.ts +9 -18
  68. package/src/plugins/breakpoints/BreakpointPlugin.ts +1 -2
  69. package/src/plugins/captions/CaptionsPlugin.ts +1 -1
  70. package/src/plugins/gesture/GesturePlugin.ts +4 -8
  71. package/src/plugins/input/AbstractControls.ts +103 -8
  72. package/src/plugins/input/Controls.test.ts +56 -0
  73. package/src/plugins/input/InputPlugin.ts +6 -14
  74. package/src/plugins/input/controlsCore.test.ts +151 -0
  75. package/src/plugins/input/controlsCore.ts +162 -0
  76. package/src/plugins/input/keyboard/KeyboardControls.ts +84 -0
  77. package/src/plugins/input/keyboard/index.ts +1 -1
  78. package/src/plugins/input/touch/VirtualControls.ts +41 -142
  79. package/src/version.ts +1 -1
  80. package/lib/plugins/input/keyboard/KeyboardContols.d.ts +0 -33
  81. package/lib/plugins/input/keyboard/KeyboardContols.d.ts.map +0 -1
  82. package/lib/registries-m2vKTIih.js.map +0 -1
  83. package/src/plugins/input/keyboard/KeyboardContols.ts +0 -206
@@ -1,21 +1,116 @@
1
1
  import type { ControlsActionMap } from '../..';
2
- import { IApplication } from '../../core';
3
2
  import { Application } from '../../core/Application';
3
+ import { WithSignals } from '../../mixins/signals';
4
+ import { Logger } from '../../utils';
5
+ import type { Action } from '../actions';
6
+ import { buildDownMaps, buildUpMap, isInputActive, type SchemeSection } from './controlsCore';
4
7
 
5
- export class AbstractControls {
8
+ /**
9
+ * Terminates the destroy chain — WithSignals' destroy() calls super.destroy().
10
+ * Also the binding root: `bindAllMethods` in an adapter binds everything down
11
+ * to here and stops, exactly like `Application` / `Plugin` / `Container`.
12
+ */
13
+ class ControlsRoot {
14
+ private static readonly __caper_method_binding_root = true;
15
+
16
+ destroy(): void {}
17
+ }
18
+
19
+ /**
20
+ * Shared machinery for every controls adapter: scheme storage, context-gated
21
+ * down/up map building (rebuilt whenever the action context changes), the
22
+ * `isActionActive` query and the ticker hookup. Adapters supply the scheme
23
+ * sections, the "is this input down" predicates and the dispatch loop.
24
+ */
25
+ export abstract class AbstractControls extends WithSignals(ControlsRoot) {
6
26
  protected scheme: any;
27
+ /** `+` combinations, longest first — the update loop relies on that order. */
28
+ protected combinations: string[][] = [];
29
+ protected combinationsMap: Map<string[], Action> = new Map();
30
+ protected activeDownInputs: Map<string, Action> = new Map();
31
+ protected activeUpInputs: Map<string, Action> = new Map();
32
+ private _warnedMissingActions: Set<string> = new Set();
7
33
 
8
- get app(): IApplication {
34
+ get app() {
9
35
  return Application.getInstance();
10
36
  }
11
37
 
12
38
  initialize(scheme: Partial<ControlsActionMap>) {
13
39
  this.scheme = scheme;
40
+ this.addSignalConnection(this.app.signal.onActionContextChanged.connect(this._sortActions));
41
+ this._sortActions();
14
42
  }
15
43
 
16
- /**
17
- * Terminates the destroy chain — subclasses are built on WithSignals, whose destroy()
18
- * calls super.destroy().
19
- */
20
- destroy(): void {}
44
+ public connect(): void {
45
+ this.app.ticker.add(this._update);
46
+ }
47
+
48
+ public destroy(): void {
49
+ this.app.ticker.remove(this._update);
50
+ super.destroy();
51
+ }
52
+
53
+ isActionActive(action: Action): boolean {
54
+ const input = this.scheme?.down?.[action] ?? null;
55
+ if (!input) {
56
+ return false;
57
+ }
58
+ if (Array.isArray(input)) {
59
+ return input.some((item: string) => this._isInputActive(item));
60
+ }
61
+ return this._isInputActive(input);
62
+ }
63
+
64
+ /** The `down` section of the adapter's scheme. */
65
+ protected get downSection(): SchemeSection | undefined {
66
+ return this.scheme?.down;
67
+ }
68
+
69
+ /** The `up` section of the adapter's scheme. */
70
+ protected get upSection(): SchemeSection | undefined {
71
+ return this.scheme?.up;
72
+ }
73
+
74
+ /** Prefix for scheme warnings — a field, so minification cannot rename it away. */
75
+ protected abstract readonly logLabel: string;
76
+
77
+ /** Whether a single input is currently down. */
78
+ protected abstract isInputDown(id: string): boolean;
79
+
80
+ /** Whether an input counts as down *as part of a combination*. */
81
+ protected isComboPartDown(id: string): boolean {
82
+ return this.isInputDown(id);
83
+ }
84
+
85
+ /** Dispatch actions for the current input state — added to the ticker by {@link connect}. */
86
+ protected abstract _update(): void;
87
+
88
+ protected _isInputActive(input: string): boolean {
89
+ return isInputActive(input, this.isInputDown, this.isComboPartDown);
90
+ }
91
+
92
+ /** Rebuild the down/up maps for the current action context. */
93
+ protected _sortActions(): void {
94
+ const actions = this.app.actionsPlugin.getActions();
95
+ const context = this.app.actionContext as string;
96
+
97
+ const { combinations, combinationsMap, singles } = buildDownMaps<Action>(
98
+ this.downSection,
99
+ actions,
100
+ context,
101
+ this._warnMissingAction,
102
+ );
103
+ this.combinations = combinations;
104
+ this.combinationsMap = combinationsMap;
105
+ this.activeDownInputs = singles;
106
+ this.activeUpInputs = buildUpMap<Action>(this.upSection, actions, context, this._warnMissingAction);
107
+ }
108
+
109
+ protected _warnMissingAction(key: string): void {
110
+ if (this._warnedMissingActions.has(key)) {
111
+ return;
112
+ }
113
+ this._warnedMissingActions.add(key);
114
+ Logger.warn(`${this.logLabel}: scheme references unknown action "${key}"`);
115
+ }
21
116
  }
@@ -10,6 +10,7 @@ vi.mock('../../core/Application', () => ({
10
10
  }));
11
11
 
12
12
  import type { IButton } from '../../ui';
13
+ import { Logger } from '../../utils';
13
14
  import { Controls } from './Controls';
14
15
 
15
16
  type FakeApp = {
@@ -35,6 +36,11 @@ function pressKey(key: string) {
35
36
  keyDown.emit({ event: { key }, key });
36
37
  }
37
38
 
39
+ function releaseKey(key: string) {
40
+ app.keyboard.keysDown.delete(key);
41
+ keyUp.emit({ event: { key }, key });
42
+ }
43
+
38
44
  function makeButton(id: string): IButton {
39
45
  return {
40
46
  id,
@@ -132,6 +138,56 @@ describe('Controls with an unmapped action in the scheme', () => {
132
138
  });
133
139
  });
134
140
 
141
+ describe('Controls up maps', () => {
142
+ it('dispatches a keyboard up action that is in context', () => {
143
+ const controls = new Controls();
144
+ controls.initialize({ keyboard: { up: { jump: 'A' } } } as any);
145
+ controls.connect();
146
+
147
+ pressKey('A');
148
+ releaseKey('A');
149
+
150
+ expect(app.action).toHaveBeenCalledWith('jump', { combination: false, inputState: 'up', key: 'A' });
151
+ });
152
+
153
+ it('does not dispatch a keyboard up action that is out of context', () => {
154
+ actions.pause = { context: 'menu', input: [] };
155
+
156
+ const controls = new Controls();
157
+ controls.initialize({ keyboard: { up: { pause: 'A' } } } as any);
158
+ controls.connect();
159
+
160
+ pressKey('A');
161
+ releaseKey('A');
162
+
163
+ expect(app.action).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it('warns once for an unknown action in the keyboard up map', () => {
167
+ const warn = vi.spyOn(Logger, 'warn').mockImplementation(() => undefined);
168
+
169
+ const controls = new Controls();
170
+ controls.initialize({ keyboard: { up: { not_an_action: 'A' } } } as any);
171
+ controls.connect();
172
+
173
+ expect(warn).toHaveBeenCalledTimes(1);
174
+ warn.mockRestore();
175
+ });
176
+
177
+ it('dispatches a virtual up action declared as an array', () => {
178
+ const controls = new Controls();
179
+ controls.initialize({ touch: { up: { jump: ['btn-a'] } } } as any);
180
+ controls.connect();
181
+
182
+ const button = makeButton('btn-a');
183
+ controls.virtual.addButton(button);
184
+ button.onDown.emit();
185
+ button.onUp.emit();
186
+
187
+ expect(app.action).toHaveBeenCalledWith('jump', { combination: false, inputState: 'up', button: 'btn-a' });
188
+ });
189
+ });
190
+
135
191
  describe('VirtualControls.removeButton', () => {
136
192
  it('stops a removed button from driving action state', () => {
137
193
  const controls = new Controls();
@@ -70,11 +70,11 @@ export class InputPlugin extends Plugin<InputManagerOptions> implements IInputPl
70
70
 
71
71
  app.stage.eventMode = 'static';
72
72
  this._canvas = app.canvas as HTMLCanvasElement;
73
- this._canvas.addEventListener('pointerdown', this._onPointerDown);
74
- this._canvas.addEventListener('pointermove', this._onPointerMove);
75
- window.addEventListener('keydown', this._onKeyDown);
76
- window.addEventListener('gamepadconnected', this._onGamepadConnected);
77
- window.addEventListener('gamepaddisconnected', this._onGamepadDisconnected);
73
+ this.listen(this._canvas, 'pointerdown', this._onPointerDown as EventListener);
74
+ this.listen(this._canvas, 'pointermove', this._onPointerMove as EventListener);
75
+ this.listen(window, 'keydown', this._onKeyDown);
76
+ this.listen(window, 'gamepadconnected', this._onGamepadConnected as EventListener);
77
+ this.listen(window, 'gamepaddisconnected', this._onGamepadDisconnected as EventListener);
78
78
 
79
79
  if (this._options.controls) {
80
80
  this.controls.initialize(this._options.controls);
@@ -88,15 +88,7 @@ export class InputPlugin extends Plugin<InputManagerOptions> implements IInputPl
88
88
  }
89
89
 
90
90
  destroy(): void {
91
- // unregister all event listeners
92
- if (this._canvas) {
93
- this._canvas.removeEventListener('pointerdown', this._onPointerDown);
94
- this._canvas.removeEventListener('pointermove', this._onPointerMove);
95
- this._canvas = null;
96
- }
97
- window.removeEventListener('keydown', this._onKeyDown);
98
- window.removeEventListener('gamepadconnected', this._onGamepadConnected);
99
- window.removeEventListener('gamepaddisconnected', this._onGamepadDisconnected);
91
+ this._canvas = null;
100
92
 
101
93
  this.controls.destroy();
102
94
 
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { buildDownMaps, buildUpMap, contextAllows, evaluateCombinations, isInputActive } from './controlsCore';
4
+
5
+ const anywhere = { context: '*' as const };
6
+
7
+ describe('contextAllows', () => {
8
+ it("allows every context for '*'", () => {
9
+ expect(contextAllows('*', 'default')).toBe(true);
10
+ expect(contextAllows('*', 'menu')).toBe(true);
11
+ });
12
+
13
+ it('matches a string context exactly', () => {
14
+ expect(contextAllows('menu', 'menu')).toBe(true);
15
+ expect(contextAllows('menu', 'game')).toBe(false);
16
+ });
17
+
18
+ it('does not treat a substring as a match', () => {
19
+ expect(contextAllows('game_over', 'game')).toBe(false);
20
+ });
21
+
22
+ it('matches any member of an array context', () => {
23
+ expect(contextAllows(['menu', 'game'], 'game')).toBe(true);
24
+ expect(contextAllows(['menu', 'game'], 'pause')).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe('buildDownMaps', () => {
29
+ it('splits singles from combinations and sorts combinations longest first', () => {
30
+ const { combinations, combinationsMap, singles } = buildDownMaps(
31
+ { jump: 'A', special: ['B+C', 'D+E+F'] },
32
+ { jump: anywhere, special: anywhere },
33
+ 'default',
34
+ vi.fn(),
35
+ );
36
+
37
+ expect([...singles]).toEqual([['A', 'jump']]);
38
+ expect(combinations).toEqual([
39
+ ['D', 'E', 'F'],
40
+ ['B', 'C'],
41
+ ]);
42
+ expect(combinationsMap.get(combinations[0])).toBe('special');
43
+ expect(combinationsMap.get(combinations[1])).toBe('special');
44
+ });
45
+
46
+ it('skips actions that are out of context', () => {
47
+ const { singles } = buildDownMaps(
48
+ { jump: 'A', pause: 'B' },
49
+ { jump: anywhere, pause: { context: 'menu' } },
50
+ 'default',
51
+ vi.fn(),
52
+ );
53
+
54
+ expect([...singles]).toEqual([['A', 'jump']]);
55
+ });
56
+
57
+ it('warns once per unknown action and skips it', () => {
58
+ const warn = vi.fn();
59
+ const { singles } = buildDownMaps({ nope: ['A', 'B'] }, {}, 'default', warn);
60
+
61
+ expect(singles.size).toBe(0);
62
+ expect(warn).toHaveBeenCalledTimes(1);
63
+ expect(warn).toHaveBeenCalledWith('nope');
64
+ });
65
+
66
+ it('handles a missing section', () => {
67
+ const { combinations, singles } = buildDownMaps(undefined, {}, 'default', vi.fn());
68
+
69
+ expect(combinations).toEqual([]);
70
+ expect(singles.size).toBe(0);
71
+ });
72
+ });
73
+
74
+ describe('buildUpMap', () => {
75
+ it('normalizes a single input and an array of inputs', () => {
76
+ const map = buildUpMap({ jump: 'A', pause: ['B', 'C'] }, { jump: anywhere, pause: anywhere }, 'default', vi.fn());
77
+
78
+ expect([...map]).toEqual([
79
+ ['A', 'jump'],
80
+ ['B', 'pause'],
81
+ ['C', 'pause'],
82
+ ]);
83
+ });
84
+
85
+ it('skips out-of-context actions and warns once for unknown ones', () => {
86
+ const warn = vi.fn();
87
+ const map = buildUpMap(
88
+ { jump: 'A', pause: 'B', nope: 'C' },
89
+ { jump: anywhere, pause: { context: 'menu' } },
90
+ 'default',
91
+ warn,
92
+ );
93
+
94
+ expect([...map]).toEqual([['A', 'jump']]);
95
+ expect(warn).toHaveBeenCalledTimes(1);
96
+ expect(warn).toHaveBeenCalledWith('nope');
97
+ });
98
+ });
99
+
100
+ describe('isInputActive', () => {
101
+ it('uses the down predicate for a single input', () => {
102
+ const isDown = (id: string) => id === 'A';
103
+
104
+ expect(isInputActive('A', isDown)).toBe(true);
105
+ expect(isInputActive('B', isDown)).toBe(false);
106
+ });
107
+
108
+ it('requires every part of a combination', () => {
109
+ const down = new Set(['A', 'B']);
110
+ const isDown = (id: string) => down.has(id);
111
+
112
+ expect(isInputActive('A+B', isDown)).toBe(true);
113
+ expect(isInputActive('A+C', isDown)).toBe(false);
114
+ });
115
+
116
+ it('uses the combination predicate for combination parts only', () => {
117
+ const isDown = (id: string) => id === 'A';
118
+ const isComboPartDown = (id: string) => id === 'A' || id === 'joystick_up';
119
+
120
+ expect(isInputActive('A+joystick_up', isDown, isComboPartDown)).toBe(true);
121
+ expect(isInputActive('joystick_up', isDown, isComboPartDown)).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe('evaluateCombinations', () => {
126
+ it('fires a combination whose inputs are all down and eliminates its inputs', () => {
127
+ const combinations = [['A', 'B']];
128
+ const { fired, eliminated } = evaluateCombinations(combinations, (id) => id === 'A' || id === 'B');
129
+
130
+ expect(fired).toEqual([['A', 'B']]);
131
+ expect([...eliminated]).toEqual(['A', 'B']);
132
+ });
133
+
134
+ it('does not fire a combination that shares an input with an already fired one', () => {
135
+ const combinations = [
136
+ ['A', 'B', 'C'],
137
+ ['A', 'B'],
138
+ ];
139
+ const down = new Set(['A', 'B', 'C']);
140
+ const { fired } = evaluateCombinations(combinations, (id) => down.has(id));
141
+
142
+ expect(fired).toEqual([['A', 'B', 'C']]);
143
+ });
144
+
145
+ it('fires nothing when no combination is fully down', () => {
146
+ const { fired, eliminated } = evaluateCombinations([['A', 'B']], (id) => id === 'A');
147
+
148
+ expect(fired).toEqual([]);
149
+ expect(eliminated.size).toBe(0);
150
+ });
151
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Pure scheme processing shared by every controls adapter — context gating,
3
+ * down/up map building and combination elimination. No caper/Pixi imports so
4
+ * it stays trivially unit-testable, and so keyboard and virtual input can not
5
+ * drift apart again.
6
+ *
7
+ * `A` is the adapter's action-id type; ids are plain object keys at runtime.
8
+ */
9
+
10
+ /** One `down` / `up` / `joystick` block of a controls scheme. */
11
+ export interface SchemeSection {
12
+ [actionId: string]: string | string[] | undefined;
13
+ }
14
+
15
+ /** The part of a registered action this module cares about. */
16
+ export interface ActionLike {
17
+ context: string | string[];
18
+ }
19
+
20
+ /** The down-map trio: combinations (longest first), their action lookup, and single inputs. */
21
+ export interface DownMaps<A = string> {
22
+ combinations: string[][];
23
+ combinationsMap: Map<string[], A>;
24
+ singles: Map<string, A>;
25
+ }
26
+
27
+ /**
28
+ * Whether an action registered for `actionContext` may fire in `current`.
29
+ * Matching is exact — a string context is compared with `===`, never with
30
+ * `includes`, so `'game_over'` does not swallow `'game'`.
31
+ */
32
+ export function contextAllows(actionContext: string | string[], current: string): boolean {
33
+ if (actionContext === '*') {
34
+ return true;
35
+ }
36
+ if (Array.isArray(actionContext)) {
37
+ return actionContext.includes(current);
38
+ }
39
+ return actionContext === current;
40
+ }
41
+
42
+ /**
43
+ * Build the down maps for a scheme section. Unknown actions are reported to
44
+ * `warn` (once per action) and skipped; out-of-context actions are skipped
45
+ * silently. Combinations are sorted largest first so the elimination pass in
46
+ * {@link evaluateCombinations} prefers the most specific match.
47
+ */
48
+ export function buildDownMaps<A = string>(
49
+ section: SchemeSection | undefined,
50
+ actions: Record<string, ActionLike | undefined>,
51
+ currentContext: string,
52
+ warn: (actionId: string) => void,
53
+ ): DownMaps<A> {
54
+ const combinations: string[][] = [];
55
+ const combinationsMap = new Map<string[], A>();
56
+ const singles = new Map<string, A>();
57
+
58
+ forEachInput<A>(section, actions, currentContext, warn, (key, input) => {
59
+ if (input.includes('+')) {
60
+ const combo = input.split('+');
61
+ combinations.push(combo);
62
+ combinationsMap.set(combo, key);
63
+ } else {
64
+ singles.set(input, key);
65
+ }
66
+ });
67
+
68
+ // sort them from the largest to smallest
69
+ combinations.sort((a, b) => b.length - a.length);
70
+
71
+ return { combinations, combinationsMap, singles };
72
+ }
73
+
74
+ /**
75
+ * Build the up map for a scheme section — same gating and warning rules as
76
+ * {@link buildDownMaps}, but every input is a single value.
77
+ */
78
+ export function buildUpMap<A = string>(
79
+ section: SchemeSection | undefined,
80
+ actions: Record<string, ActionLike | undefined>,
81
+ currentContext: string,
82
+ warn: (actionId: string) => void,
83
+ ): Map<string, A> {
84
+ const map = new Map<string, A>();
85
+ forEachInput<A>(section, actions, currentContext, warn, (key, input) => {
86
+ map.set(input, key);
87
+ });
88
+ return map;
89
+ }
90
+
91
+ /**
92
+ * Whether an input is active: a single input is tested with `isDown`, a `+`
93
+ * separated combination requires every part to satisfy `isComboPartDown`
94
+ * (which defaults to `isDown` — the virtual adapter uses the split to let a
95
+ * joystick direction take part in a combination but not stand alone).
96
+ */
97
+ export function isInputActive(
98
+ input: string,
99
+ isDown: (id: string) => boolean,
100
+ isComboPartDown: (id: string) => boolean = isDown,
101
+ ): boolean {
102
+ if (input.includes('+')) {
103
+ return input.split('+').every((id) => isComboPartDown(id));
104
+ }
105
+ return isDown(input);
106
+ }
107
+
108
+ /**
109
+ * Walk combinations (already sorted largest first) and fire each one whose
110
+ * inputs are all down and none of which a longer combination already claimed.
111
+ * Returns the fired combinations plus the claimed inputs, so the caller can
112
+ * skip them when dispatching single inputs.
113
+ */
114
+ export function evaluateCombinations(
115
+ combinations: string[][],
116
+ isDown: (id: string) => boolean,
117
+ ): { fired: string[][]; eliminated: Set<string> } {
118
+ const fired: string[][] = [];
119
+ const eliminated = new Set<string>();
120
+
121
+ for (let i = 0; i < combinations.length; i++) {
122
+ const combination = combinations[i];
123
+ if (combination.some((id) => eliminated.has(id))) {
124
+ continue;
125
+ }
126
+ if (combination.every((id) => isDown(id))) {
127
+ combination.forEach((id) => eliminated.add(id));
128
+ fired.push(combination);
129
+ }
130
+ }
131
+
132
+ return { fired, eliminated };
133
+ }
134
+
135
+ /** Shared scheme walk: resolve the action, gate it, normalize its inputs. */
136
+ function forEachInput<A>(
137
+ section: SchemeSection | undefined,
138
+ actions: Record<string, ActionLike | undefined>,
139
+ currentContext: string,
140
+ warn: (actionId: string) => void,
141
+ visit: (actionId: A, input: string) => void,
142
+ ): void {
143
+ if (!section) {
144
+ return;
145
+ }
146
+ Object.keys(section).forEach((key) => {
147
+ const action = actions[key];
148
+ if (!action) {
149
+ warn(key);
150
+ return;
151
+ }
152
+ if (!contextAllows(action.context, currentContext)) {
153
+ return;
154
+ }
155
+ const input = section[key];
156
+ if (!input) {
157
+ return;
158
+ }
159
+ const inputs = Array.isArray(input) ? input : [input];
160
+ inputs.forEach((inputString) => visit(key as unknown as A, inputString));
161
+ });
162
+ }
@@ -0,0 +1,84 @@
1
+ import { bindAllMethods } from '../../../utils';
2
+ import { normalizeKey, type KeyboardEventDetail } from '../../KeyboardPlugin';
3
+ import { AbstractControls } from '../AbstractControls';
4
+ import { evaluateCombinations } from '../controlsCore';
5
+ import type { ControlsActionMap, KeyboardControlsMap } from '../types';
6
+
7
+ export class KeyboardControls extends AbstractControls {
8
+ protected scheme: Partial<KeyboardControlsMap>;
9
+ protected readonly logLabel = 'KeyboardControls';
10
+ private _singleDownKeys: Set<string> = new Set();
11
+
12
+ constructor() {
13
+ super();
14
+ bindAllMethods(this);
15
+ }
16
+
17
+ public initialize(scheme: Partial<KeyboardControlsMap>): void {
18
+ super.initialize(scheme as Partial<ControlsActionMap>);
19
+ }
20
+
21
+ public connect() {
22
+ this.addSignalConnection(
23
+ this.app.keyboard.onKeyDown().connect(this._handleKeyDown),
24
+ this.app.keyboard.onKeyUp().connect(this._handleKeyUp),
25
+ );
26
+
27
+ super.connect();
28
+ }
29
+
30
+ protected isInputDown(id: string): boolean {
31
+ return this._singleDownKeys.has(id);
32
+ }
33
+
34
+ private _handleKeyDown(detail: KeyboardEventDetail): void {
35
+ const key = normalizeKey(detail.event.key);
36
+ this._singleDownKeys.add(key);
37
+ }
38
+
39
+ private _handleKeyUp(detail: KeyboardEventDetail): void {
40
+ const key = normalizeKey(detail.event.key);
41
+ this._singleDownKeys.delete(key);
42
+
43
+ const action = this.activeUpInputs.get(key);
44
+ if (action) {
45
+ this.app.action(action, { combination: false, inputState: 'up', key });
46
+ }
47
+ }
48
+
49
+ protected _update() {
50
+ if (!this.app.keyboard) {
51
+ return;
52
+ }
53
+ const keysDown = this.app.keyboard.keysDown;
54
+ if (keysDown.size === 0) {
55
+ return;
56
+ }
57
+
58
+ // this.combinations is already sorted from largest to smallest
59
+ const { fired, eliminated } = evaluateCombinations(this.combinations, (key) => keysDown.has(key));
60
+ fired.forEach((combination) => {
61
+ const action = this.combinationsMap.get(combination);
62
+ if (action) {
63
+ this.app.action(action, {
64
+ key: combination,
65
+ combination: true,
66
+ inputState: 'down',
67
+ });
68
+ }
69
+ });
70
+
71
+ // order doesn't matter here
72
+ this._singleDownKeys.forEach((key) => {
73
+ if (eliminated.has(key)) {
74
+ return;
75
+ }
76
+ if (keysDown.has(key)) {
77
+ const action = this.activeDownInputs.get(key);
78
+ if (action) {
79
+ this.app.action(action, { key, combination: false, inputState: 'down' });
80
+ }
81
+ }
82
+ });
83
+ }
84
+ }
@@ -1,2 +1,2 @@
1
1
  export * from './interfaces';
2
- export * from './KeyboardContols';
2
+ export * from './KeyboardControls';