@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.
- package/build/assetpack.mjs +90 -0
- package/build/plugins/pruneFallbacks.mjs +25 -2
- package/lib/{CaptionsPlugin-BLbLl3WB.js → CaptionsPlugin-cSJj-R9M.js} +4 -4
- package/lib/{CaptionsPlugin-BLbLl3WB.js.map → CaptionsPlugin-cSJj-R9M.js.map} +1 -1
- package/lib/{DataAdapter-aHSNM_26.js → DataAdapter-D2tc6hx9.js} +5 -5
- package/lib/{DataAdapter-aHSNM_26.js.map → DataAdapter-D2tc6hx9.js.map} +1 -1
- package/lib/{DebugRenderer-8LjHtBzA.js → DebugRenderer-CGUKy6Fy.js} +2 -2
- package/lib/{DebugRenderer-8LjHtBzA.js.map → DebugRenderer-CGUKy6Fy.js.map} +1 -1
- package/lib/{DevToolsPlugin-Vrk6aOtS.js → DevToolsPlugin-B8GX3H9b.js} +2 -2
- package/lib/{DevToolsPlugin-Vrk6aOtS.js.map → DevToolsPlugin-B8GX3H9b.js.map} +1 -1
- package/lib/{GSAPPlugin-Hm3F4zdg.js → GSAPPlugin-Br9AjuTD.js} +5 -5
- package/lib/{GSAPPlugin-Hm3F4zdg.js.map → GSAPPlugin-Br9AjuTD.js.map} +1 -1
- package/lib/{LayoutPlugin-Dpgeawfw.js → LayoutPlugin-BX3OP7pw.js} +4 -4
- package/lib/{LayoutPlugin-Dpgeawfw.js.map → LayoutPlugin-BX3OP7pw.js.map} +1 -1
- package/lib/{SpinePlugin-B08yAw1S.js → SpinePlugin-C-JDdXpf.js} +2 -2
- package/lib/{SpinePlugin-B08yAw1S.js.map → SpinePlugin-C-JDdXpf.js.map} +1 -1
- package/lib/{StatsPlugin-D2Xa5j97.js → StatsPlugin-yJlJtoQ2.js} +7 -10
- package/lib/{StatsPlugin-D2Xa5j97.js.map → StatsPlugin-yJlJtoQ2.js.map} +1 -1
- package/lib/{VoiceOverPlugin-qtFlqpRn.js → VoiceOverPlugin-DjSZp4Ko.js} +4 -4
- package/lib/{VoiceOverPlugin-qtFlqpRn.js.map → VoiceOverPlugin-DjSZp4Ko.js.map} +1 -1
- package/lib/caper.mjs +156 -1927
- package/lib/caper.mjs.map +1 -1
- package/lib/const-C3sxeYhl.js +1746 -0
- package/lib/const-C3sxeYhl.js.map +1 -0
- package/lib/core/create.d.ts.map +1 -1
- package/lib/padding-1kHDpGlQ.js +41 -0
- package/lib/padding-1kHDpGlQ.js.map +1 -0
- package/lib/plugins/FullScreenPlugin.d.ts +0 -5
- package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
- package/lib/plugins/KeyboardPlugin.d.ts +0 -1
- package/lib/plugins/KeyboardPlugin.d.ts.map +1 -1
- package/lib/plugins/Plugin.d.ts +101 -2
- package/lib/plugins/Plugin.d.ts.map +1 -1
- package/lib/plugins/SceneManagerPlugin.d.ts +0 -1
- package/lib/plugins/SceneManagerPlugin.d.ts.map +1 -1
- package/lib/plugins/StatsPlugin.d.ts +0 -1
- package/lib/plugins/StatsPlugin.d.ts.map +1 -1
- package/lib/plugins/TimerPlugin.d.ts.map +1 -1
- package/lib/plugins/WebEventsPlugin.d.ts +0 -1
- package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
- package/lib/plugins/breakpoints/BreakpointPlugin.d.ts.map +1 -1
- package/lib/plugins/gesture/GesturePlugin.d.ts.map +1 -1
- package/lib/plugins/input/AbstractControls.d.ts +46 -7
- package/lib/plugins/input/AbstractControls.d.ts.map +1 -1
- package/lib/plugins/input/InputPlugin.d.ts.map +1 -1
- package/lib/plugins/input/controlsCore.d.ts +58 -0
- package/lib/plugins/input/controlsCore.d.ts.map +1 -0
- package/lib/plugins/input/controlsCore.test.d.ts +2 -0
- package/lib/plugins/input/controlsCore.test.d.ts.map +1 -0
- package/lib/plugins/input/keyboard/KeyboardControls.d.ts +15 -0
- package/lib/plugins/input/keyboard/KeyboardControls.d.ts.map +1 -0
- package/lib/plugins/input/keyboard/index.d.ts +1 -1
- package/lib/plugins/input/keyboard/index.d.ts.map +1 -1
- package/lib/plugins/input/touch/VirtualControls.d.ts +7 -19
- package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
- package/lib/{registries-m2vKTIih.js → registries-BfV-VF7a.js} +565 -552
- package/lib/registries-BfV-VF7a.js.map +1 -0
- package/package.json +1 -1
- package/src/core/create.ts +11 -0
- package/src/plugins/FullScreenPlugin.ts +5 -18
- package/src/plugins/KeyboardPlugin.ts +3 -10
- package/src/plugins/Plugin.test.ts +130 -2
- package/src/plugins/Plugin.ts +159 -3
- package/src/plugins/SceneManagerPlugin.ts +3 -8
- package/src/plugins/StatsPlugin.ts +3 -10
- package/src/plugins/TimerPlugin.ts +1 -2
- package/src/plugins/WebEventsPlugin.ts +9 -18
- package/src/plugins/breakpoints/BreakpointPlugin.ts +1 -2
- package/src/plugins/captions/CaptionsPlugin.ts +1 -1
- package/src/plugins/gesture/GesturePlugin.ts +4 -8
- package/src/plugins/input/AbstractControls.ts +103 -8
- package/src/plugins/input/Controls.test.ts +56 -0
- package/src/plugins/input/InputPlugin.ts +6 -14
- package/src/plugins/input/controlsCore.test.ts +151 -0
- package/src/plugins/input/controlsCore.ts +162 -0
- package/src/plugins/input/keyboard/KeyboardControls.ts +84 -0
- package/src/plugins/input/keyboard/index.ts +1 -1
- package/src/plugins/input/touch/VirtualControls.ts +41 -142
- package/src/version.ts +1 -1
- package/lib/plugins/input/keyboard/KeyboardContols.d.ts +0 -33
- package/lib/plugins/input/keyboard/KeyboardContols.d.ts.map +0 -1
- package/lib/registries-m2vKTIih.js.map +0 -1
- 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
|
-
|
|
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()
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
74
|
-
this._canvas
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 './
|
|
2
|
+
export * from './KeyboardControls';
|