@gg-web-engine/mobile-controls 0.0.0-stage → 0.0.78

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 (56) hide show
  1. package/README.md +151 -2
  2. package/dist/controls/touch-axis-control.d.ts +38 -0
  3. package/dist/controls/touch-axis-control.js +91 -0
  4. package/dist/controls/touch-button.d.ts +49 -0
  5. package/dist/controls/touch-button.js +111 -0
  6. package/dist/controls/touch-control.d.ts +76 -0
  7. package/dist/controls/touch-control.js +151 -0
  8. package/dist/controls/touch-dpad.d.ts +27 -0
  9. package/dist/controls/touch-dpad.js +61 -0
  10. package/dist/controls/touch-look-area.d.ts +35 -0
  11. package/dist/controls/touch-look-area.js +61 -0
  12. package/dist/controls/touch-stick.d.ts +37 -0
  13. package/dist/controls/touch-stick.js +71 -0
  14. package/dist/icons.d.ts +19 -0
  15. package/dist/icons.js +20 -0
  16. package/dist/index.d.ts +15 -0
  17. package/dist/index.js +15 -0
  18. package/dist/inputs/tilt.input.d.ts +40 -0
  19. package/dist/inputs/tilt.input.js +106 -0
  20. package/dist/layouts/car.layout.d.ts +31 -0
  21. package/dist/layouts/car.layout.js +99 -0
  22. package/dist/layouts/character-2d.layout.d.ts +15 -0
  23. package/dist/layouts/character-2d.layout.js +60 -0
  24. package/dist/layouts/character.layout.d.ts +25 -0
  25. package/dist/layouts/character.layout.js +87 -0
  26. package/dist/layouts/free-camera.layout.d.ts +16 -0
  27. package/dist/layouts/free-camera.layout.js +73 -0
  28. package/dist/mobile-controls-layout.d.ts +54 -0
  29. package/dist/mobile-controls-layout.js +47 -0
  30. package/dist/mobile-controls.entity.d.ts +112 -0
  31. package/dist/mobile-controls.entity.js +274 -0
  32. package/dist/styles.d.ts +16 -0
  33. package/dist/styles.js +158 -0
  34. package/package.json +68 -4
  35. package/src/controls/touch-axis-control.ts +105 -0
  36. package/src/controls/touch-button.ts +136 -0
  37. package/src/controls/touch-control.ts +204 -0
  38. package/src/controls/touch-dpad.ts +78 -0
  39. package/src/controls/touch-look-area.ts +78 -0
  40. package/src/controls/touch-stick.ts +100 -0
  41. package/src/icons.ts +23 -0
  42. package/src/index.ts +15 -0
  43. package/src/inputs/tilt.input.ts +128 -0
  44. package/src/layouts/car.layout.ts +139 -0
  45. package/src/layouts/character-2d.layout.ts +85 -0
  46. package/src/layouts/character.layout.ts +125 -0
  47. package/src/layouts/free-camera.layout.ts +103 -0
  48. package/src/mobile-controls-layout.ts +86 -0
  49. package/src/mobile-controls.entity.ts +350 -0
  50. package/src/styles.ts +160 -0
  51. package/test/helpers.ts +38 -0
  52. package/test/mobile-controls.spec.ts +204 -0
  53. package/test/tilt.input.spec.ts +95 -0
  54. package/test/touch-axis-controls.spec.ts +172 -0
  55. package/test/touch-button.spec.ts +162 -0
  56. package/tsconfig.json +12 -0
@@ -0,0 +1,204 @@
1
+ import {
2
+ CarHandlingController,
3
+ GgCarHandlingController,
4
+ IEntity,
5
+ PlayerCharacterController2d,
6
+ TickOrder,
7
+ } from '@gg-web-engine/core';
8
+ import { MOBILE_CONTROLS_STYLE_ID, MobileControls, TouchButton } from '../src';
9
+ import { pointer, TestWorld, tick } from './helpers';
10
+
11
+ class CustomController extends IEntity {
12
+ static readonly entityTypeName: string = 'CustomController';
13
+ public readonly tickOrder = TickOrder.INPUT_CONTROLLERS;
14
+ }
15
+
16
+ describe('MobileControls', () => {
17
+ let world: TestWorld;
18
+
19
+ beforeEach(async () => {
20
+ world = new TestWorld();
21
+ await world.init();
22
+ });
23
+
24
+ afterEach(() => {
25
+ world.dispose();
26
+ document.body.innerHTML = '';
27
+ });
28
+
29
+ const ids = (controls: MobileControls) =>
30
+ [...controls.element.querySelectorAll('[data-gg-mc]')].map(e => (e as HTMLElement).dataset.ggMc);
31
+ const control = (controls: MobileControls, id: string) => controls.element.querySelector(`.gg-mc-id-${id}`)!;
32
+
33
+ it('stays off the page on a device that is not touch-first, unless enabled outright', () => {
34
+ const auto = new MobileControls();
35
+ world.addEntity(auto);
36
+ expect(auto.shown).toBe(false);
37
+
38
+ auto.enabled = true;
39
+ expect(auto.shown).toBe(true);
40
+ expect(auto.element.parentElement).toBe(document.body);
41
+ expect(document.getElementById(MOBILE_CONTROLS_STYLE_ID)).not.toBeNull();
42
+ });
43
+
44
+ it('goes by the pointer media query in auto mode', () => {
45
+ window.matchMedia = ((query: string) => ({ matches: query === '(pointer: coarse)' })) as any;
46
+ const controls = new MobileControls();
47
+ world.addEntity(controls);
48
+ expect(controls.shown).toBe(true);
49
+ delete (window as any).matchMedia;
50
+ });
51
+
52
+ it('shows the layout of an active controller and swaps it as controllers change', () => {
53
+ const car = new GgCarHandlingController(world.keyboardInput, null);
54
+ world.addEntity(car);
55
+ const controls = new MobileControls({ enabled: true });
56
+ world.addEntity(controls);
57
+ expect(ids(controls)).toEqual(['steer-left', 'steer-right', 'accelerate', 'brake', 'handbrake', 'gear-down', 'gear-up']);
58
+
59
+ const player = new PlayerCharacterController2d(world.keyboardInput, null, {} as any);
60
+ player.active = false;
61
+ world.addEntity(player);
62
+ expect(ids(controls)).not.toContain('jump');
63
+
64
+ car.active = false;
65
+ player.active = true;
66
+ tick(world);
67
+ expect(ids(controls)).toEqual(['move-left', 'move-right', 'jump', 'run', 'crouch']);
68
+ expect(controls.activeControllers).toEqual([player]);
69
+
70
+ world.removeEntity(player);
71
+ expect(ids(controls)).toEqual([]);
72
+ });
73
+
74
+ it('drives the car controller from the steering and pedal buttons', () => {
75
+ const car = new GgCarHandlingController(world.keyboardInput, null, {
76
+ keymap: 'arrows',
77
+ maxSteerDeltaPerSecond: 1000,
78
+ gearUpDownKeys: ['KeyA', 'KeyZ'],
79
+ autoReverse: true,
80
+ handbrakeKey: 'Space',
81
+ });
82
+ world.addEntity(car);
83
+ const controls = new MobileControls({ enabled: true });
84
+ world.addEntity(controls);
85
+ const outputs: any[] = [];
86
+ car.carHandlingInput.output$.subscribe(o => outputs.push(o));
87
+
88
+ pointer(control(controls, 'steer-left'), 'pointerdown', 0, 0, 1);
89
+ pointer(control(controls, 'accelerate'), 'pointerdown', 0, 0, 2);
90
+ tick(world);
91
+ expect(outputs[outputs.length - 1]).toEqual({ upDown: 1, leftRight: 1 });
92
+
93
+ pointer(control(controls, 'steer-left'), 'pointerup', 0, 0, 1);
94
+ pointer(control(controls, 'accelerate'), 'pointerup', 0, 0, 2);
95
+ pointer(control(controls, 'brake'), 'pointerdown', 0, 0, 3);
96
+ tick(world, 16);
97
+ expect(outputs[outputs.length - 1]).toEqual({ upDown: -1, leftRight: 0 });
98
+ });
99
+
100
+ it('gives a bare CarHandlingController the steering and the pedals alone', () => {
101
+ world.addEntity(new CarHandlingController(world.keyboardInput));
102
+ const controls = new MobileControls({ enabled: true });
103
+ world.addEntity(controls);
104
+ expect(ids(controls)).toEqual(['steer-left', 'steer-right', 'accelerate', 'brake']);
105
+ });
106
+
107
+ it('releases what is held when the layout goes away', () => {
108
+ const player = new PlayerCharacterController2d(world.keyboardInput, null, {} as any);
109
+ world.addEntity(player);
110
+ const controls = new MobileControls({ enabled: true });
111
+ world.addEntity(controls);
112
+ const run: boolean[] = [];
113
+ world.keyboardInput.bind('ShiftLeft').subscribe(v => run.push(v));
114
+
115
+ pointer(control(controls, 'run'), 'pointerdown');
116
+ pointer(control(controls, 'run'), 'pointerup');
117
+ pointer(control(controls, 'move-right'), 'pointerdown', 0, 0, 2);
118
+ expect(run).toEqual([false, true]);
119
+ expect(player.directionsInput.direction.x).toBe(1);
120
+
121
+ controls.visible = false;
122
+ expect(run).toEqual([false, true, false]);
123
+ expect(player.directionsInput.direction.x).toBe(0);
124
+ expect(controls.shown).toBe(false);
125
+ });
126
+
127
+ it('applies layout options: variants, hidden controls, placements, icons and extra controls', () => {
128
+ world.addEntity(new GgCarHandlingController(world.keyboardInput, null));
129
+ const controls = new MobileControls({
130
+ enabled: true,
131
+ car: {
132
+ steering: 'stick',
133
+ gears: true,
134
+ hide: ['handbrake'],
135
+ placements: { brake: { left: 30 } },
136
+ icons: { accelerate: 'GO' },
137
+ extra: () => [new TouchButton({ id: 'horn', content: 'H' })],
138
+ },
139
+ });
140
+ world.addEntity(controls);
141
+
142
+ expect(ids(controls)).toEqual(['steer-stick', 'accelerate', 'brake', 'gear-down', 'gear-up', 'horn']);
143
+ expect(control(controls, 'accelerate').textContent).toBe('GO');
144
+ const brake = control(controls, 'brake') as HTMLElement;
145
+ expect(brake.style.left).toContain('30');
146
+ expect(brake.style.right).toBe('');
147
+ });
148
+
149
+ it('takes a layout for any controller class, replacing or dropping a built-in one', () => {
150
+ const custom = new CustomController();
151
+ world.addEntity(custom);
152
+ world.addEntity(new GgCarHandlingController(world.keyboardInput, null));
153
+ const controls = new MobileControls({ enabled: true, car: false });
154
+ world.addEntity(controls);
155
+ expect(ids(controls)).toEqual([]);
156
+
157
+ const pressed = jest.fn();
158
+ controls.registerLayout(CustomController, () => [new TouchButton({ id: 'fire' }).onPress(pressed)]);
159
+ expect(ids(controls)).toEqual(['fire']);
160
+ pointer(control(controls, 'fire'), 'pointerdown');
161
+ expect(pressed).toHaveBeenCalledTimes(1);
162
+
163
+ controls.registerLayout(CustomController, null);
164
+ expect(ids(controls)).toEqual([]);
165
+ });
166
+
167
+ it('keeps its own controls on top of every layout, and inside a given container', () => {
168
+ const container = document.createElement('div');
169
+ document.body.appendChild(container);
170
+ const controls = new MobileControls({ enabled: true, container });
171
+ const pause = new TouchButton({ id: 'pause' });
172
+ controls.addControls(pause);
173
+ world.addEntity(controls);
174
+ world.addEntity(new PlayerCharacterController2d(world.keyboardInput, null, {} as any));
175
+
176
+ expect(controls.element.parentElement).toBe(container);
177
+ expect(controls.element.classList.contains('gg-mc--contained')).toBe(true);
178
+ expect(ids(controls).pop()).toBe('pause');
179
+
180
+ world.removeEntity(controls);
181
+ expect(controls.shown).toBe(false);
182
+ expect(controls.element.querySelector('.gg-mc-id-jump')).toBeNull();
183
+ });
184
+
185
+ it('leaves the screen while the world input is switched off, even with the world paused, and comes back', () => {
186
+ const car = new GgCarHandlingController(world.keyboardInput, null);
187
+ world.addEntity(car);
188
+ const controls = new MobileControls({ enabled: true });
189
+ world.addEntity(controls);
190
+ const pause = new TouchButton({ id: 'pause' });
191
+ controls.addControls(pause);
192
+ expect(controls.shown).toBe(true);
193
+
194
+ // what a ScreenManager does to the world of a covered screen: no tick follows
195
+ world.pauseWorld();
196
+ world.inputEnabled = false;
197
+ expect(controls.shown).toBe(false);
198
+ expect(ids(controls)).toEqual(['pause']);
199
+
200
+ world.inputEnabled = true;
201
+ expect(controls.shown).toBe(true);
202
+ expect(ids(controls)).toContain('accelerate');
203
+ });
204
+ });
@@ -0,0 +1,95 @@
1
+ import { TiltInput } from '../src';
2
+
3
+ describe('TiltInput', () => {
4
+ const orient = (beta: number, gamma: number) => {
5
+ const event = new Event('deviceorientation');
6
+ Object.assign(event, { beta, gamma });
7
+ window.dispatchEvent(event);
8
+ };
9
+ const setScreenAngle = (angle: number) =>
10
+ Object.defineProperty(window.screen, 'orientation', { value: { angle }, configurable: true });
11
+
12
+ beforeEach(() => {
13
+ (window as any).DeviceOrientationEvent = class extends Event {};
14
+ setScreenAngle(0);
15
+ });
16
+
17
+ afterEach(() => {
18
+ delete (window as any).DeviceOrientationEvent;
19
+ });
20
+
21
+ it('reads a sideways tilt in portrait, upright or flat, with a deadzone and a limit', () => {
22
+ const tilt = new TiltInput({ maxAngle: 30, deadzone: 0 });
23
+ tilt.start();
24
+ // flat on a table, right edge lowered by 15 degrees
25
+ orient(0, 15);
26
+ expect(tilt.value).toBeCloseTo(0.5);
27
+ // held upright and rolled 15 degrees to the left like a steering wheel
28
+ orient(75, -89.999);
29
+ expect(tilt.value).toBeCloseTo(-0.5);
30
+ orient(45, 0);
31
+ expect(tilt.value).toBeCloseTo(0);
32
+ tilt.stop();
33
+ expect(tilt.value).toBe(0);
34
+ });
35
+
36
+ it('follows the screen rotation', () => {
37
+ const tilt = new TiltInput({ maxAngle: 30, deadzone: 0 });
38
+ tilt.start();
39
+ // rotated counter-clockwise: the device's bottom edge is the right edge of the screen, and
40
+ // lowering it tips the device's top edge up
41
+ setScreenAngle(90);
42
+ orient(15, 0);
43
+ expect(tilt.value).toBeCloseTo(0.5);
44
+ setScreenAngle(270);
45
+ orient(15, 0);
46
+ expect(tilt.value).toBeCloseTo(-0.5);
47
+ tilt.stop();
48
+ });
49
+
50
+ it('applies the deadzone and the invert option', () => {
51
+ const tilt = new TiltInput({ maxAngle: 32, deadzone: 2, invert: true });
52
+ tilt.start();
53
+ orient(0, 1.5);
54
+ expect(tilt.value).toBe(0);
55
+ orient(0, 17);
56
+ expect(tilt.value).toBeCloseTo(-0.5);
57
+ tilt.stop();
58
+ });
59
+
60
+ it('reports denied on a page that is not a secure context', () => {
61
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
62
+ Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
63
+ const tilt = new TiltInput({ deadzone: 0 });
64
+ const states: string[] = [];
65
+ tilt.permission$.subscribe(s => states.push(s));
66
+ tilt.start();
67
+ orient(0, 15);
68
+ expect(tilt.value).toBe(0);
69
+ expect(states).toEqual(['unknown', 'denied']);
70
+ expect(warn).toHaveBeenCalledTimes(1);
71
+ tilt.stop();
72
+ delete (window as any).isSecureContext;
73
+ warn.mockRestore();
74
+ });
75
+
76
+ it('asks for the permission on the next tap where one is needed', async () => {
77
+ const requestPermission = jest.fn().mockResolvedValue('granted');
78
+ (window as any).DeviceOrientationEvent.requestPermission = requestPermission;
79
+ const tilt = new TiltInput({ deadzone: 0 });
80
+ const states: string[] = [];
81
+ tilt.permission$.subscribe(s => states.push(s));
82
+ tilt.start();
83
+ orient(0, 15);
84
+ expect(tilt.value).toBe(0);
85
+
86
+ window.dispatchEvent(new Event('click'));
87
+ await Promise.resolve();
88
+ await Promise.resolve();
89
+ expect(requestPermission).toHaveBeenCalledTimes(1);
90
+ expect(states).toEqual(['unknown', 'granted']);
91
+ orient(0, 15);
92
+ expect(tilt.value).toBeCloseTo(0.5);
93
+ tilt.stop();
94
+ });
95
+ });
@@ -0,0 +1,172 @@
1
+ import { DirectionInput, KeyboardInput, MouseInput, Point2 } from '@gg-web-engine/core';
2
+ import { TouchDPad, TouchLookArea, TouchStick } from '../src';
3
+ import { pointer, setRect } from './helpers';
4
+
5
+ describe('TouchStick', () => {
6
+ const fixedStick = (options: any = {}) => {
7
+ const stick = new TouchStick({ travel: 50, deadzone: 0, ...options });
8
+ // base centered at (100, 100)
9
+ setRect(stick.element.firstElementChild!, 50, 50, 100, 100);
10
+ return stick;
11
+ };
12
+
13
+ it('reports the deflection from its center, y up, capped at 1', () => {
14
+ const stick = fixedStick();
15
+ pointer(stick.element, 'pointerdown', 125, 100);
16
+ expect(stick.value).toEqual({ x: 0.5, y: 0 });
17
+ pointer(stick.element, 'pointermove', 100, 50);
18
+ expect(stick.value).toEqual({ x: 0, y: 1 });
19
+ pointer(stick.element, 'pointermove', 400, 100);
20
+ expect(stick.value).toEqual({ x: 1, y: 0 });
21
+ pointer(stick.element, 'pointerup');
22
+ expect(stick.value).toEqual({ x: 0, y: 0 });
23
+ });
24
+
25
+ it('reads zero inside the deadzone and grows from zero outside of it', () => {
26
+ const stick = fixedStick({ deadzone: 0.2 });
27
+ pointer(stick.element, 'pointerdown', 105, 100);
28
+ expect(stick.value).toEqual({ x: 0, y: 0 });
29
+ pointer(stick.element, 'pointermove', 130, 100);
30
+ expect(stick.value.x).toBeCloseTo(0.5);
31
+ pointer(stick.element, 'pointermove', 150, 100);
32
+ expect(stick.value.x).toBeCloseTo(1);
33
+ });
34
+
35
+ it('moves along one axis only when restricted', () => {
36
+ const stick = fixedStick({ axes: 'x' });
37
+ pointer(stick.element, 'pointerdown', 125, 20);
38
+ expect(stick.value).toEqual({ x: 0.5, y: 0 });
39
+ });
40
+
41
+ it('centers under the finger in floating mode', () => {
42
+ const stick = new TouchStick({ mode: 'floating', travel: 50, deadzone: 0 });
43
+ setRect(stick.element, 0, 0, 400, 300);
44
+ pointer(stick.element, 'pointerdown', 200, 150);
45
+ expect(stick.value).toEqual({ x: 0, y: 0 });
46
+ pointer(stick.element, 'pointermove', 200, 175);
47
+ expect(stick.value).toEqual({ x: 0, y: -0.5 });
48
+ });
49
+
50
+ it('feeds a bound direction input and withdraws when released', () => {
51
+ const keyboard = new KeyboardInput();
52
+ keyboard.start();
53
+ const input = new DirectionInput(keyboard, 'wasd');
54
+ input.start();
55
+ const stick = fixedStick().bindDirection(input);
56
+
57
+ pointer(stick.element, 'pointerdown', 125, 75);
58
+ expect(input.direction).toEqual({ x: 0.5, y: 0.5 });
59
+ keyboard.emulateKeyDown('KeyD');
60
+ expect(input.direction).toEqual({ x: 1, y: 0.5 });
61
+ keyboard.emulateKeyUp('KeyD');
62
+ stick.dispose();
63
+ expect(input.direction).toEqual({ x: 0, y: 0 });
64
+ input.stop();
65
+ keyboard.stop();
66
+ });
67
+
68
+ it('holds bound keys past the threshold', () => {
69
+ const keyboard = new KeyboardInput();
70
+ keyboard.start();
71
+ const up: boolean[] = [];
72
+ const left: boolean[] = [];
73
+ keyboard.bind('KeyW').subscribe(v => up.push(v));
74
+ keyboard.bind('KeyA').subscribe(v => left.push(v));
75
+ const stick = fixedStick().bindKeys(keyboard, { up: 'KeyW', left: 'KeyA' });
76
+
77
+ pointer(stick.element, 'pointerdown', 100, 60);
78
+ pointer(stick.element, 'pointermove', 60, 100);
79
+ pointer(stick.element, 'pointerup');
80
+
81
+ expect(up).toEqual([false, true, false]);
82
+ expect(left).toEqual([false, true, false]);
83
+ keyboard.stop();
84
+ });
85
+
86
+ it('never turns a bound view backwards on the first frame of a deflection', () => {
87
+ const frames: FrameRequestCallback[] = [];
88
+ const raf = jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => frames.push(cb));
89
+ const now = jest.spyOn(performance, 'now').mockReturnValue(1000);
90
+ const mouse = new MouseInput();
91
+ mouse.start();
92
+ const deltas: Point2[] = [];
93
+ mouse.delta$.subscribe(d => deltas.push(d));
94
+ const stick = fixedStick().bindLook(mouse, 1000);
95
+
96
+ pointer(stick.element, 'pointerdown', 150, 100);
97
+ // a frame that began before the stick was touched
98
+ frames.shift()!(990);
99
+ frames.shift()!(1006);
100
+
101
+ expect(deltas[0].x).toBe(0);
102
+ expect(deltas[1].x).toBeCloseTo(16);
103
+ stick.dispose();
104
+ mouse.stop();
105
+ raf.mockRestore();
106
+ now.mockRestore();
107
+ });
108
+ });
109
+
110
+ describe('TouchDPad', () => {
111
+ const dpad = (options: any = {}) => {
112
+ const control = new TouchDPad(options);
113
+ setRect(control.element, 0, 0, 100, 100);
114
+ return control;
115
+ };
116
+
117
+ it('snaps to eight directions and lets a finger slide between them', () => {
118
+ const control = dpad();
119
+ pointer(control.element, 'pointerdown', 50, 10);
120
+ expect(control.value).toEqual({ x: 0, y: 1 });
121
+ expect(control.element.querySelector('.gg-mc-dpad-arm--up')!.classList.contains('gg-mc-active')).toBe(true);
122
+ pointer(control.element, 'pointermove', 90, 10);
123
+ expect(control.value).toEqual({ x: 1, y: 1 });
124
+ pointer(control.element, 'pointermove', 90, 50);
125
+ expect(control.value).toEqual({ x: 1, y: 0 });
126
+ pointer(control.element, 'pointermove', 50, 52);
127
+ expect(control.value).toEqual({ x: 0, y: 0 });
128
+ pointer(control.element, 'pointerup');
129
+ expect(control.element.querySelector('.gg-mc-active')).toBeNull();
130
+ });
131
+
132
+ it('snaps to four directions without diagonals', () => {
133
+ const control = dpad({ diagonals: false });
134
+ pointer(control.element, 'pointerdown', 90, 20);
135
+ expect(Math.abs(control.value.x) + Math.abs(control.value.y)).toBe(1);
136
+ });
137
+ });
138
+
139
+ describe('TouchLookArea', () => {
140
+ it('reports drags through a bound mouse input, scaled', () => {
141
+ const mouse = new MouseInput();
142
+ mouse.start();
143
+ const deltas: Point2[] = [];
144
+ mouse.delta$.subscribe(d => deltas.push(d));
145
+ const area = new TouchLookArea().bindMouse(mouse, 2);
146
+
147
+ pointer(area.element, 'pointerdown', 10, 10);
148
+ pointer(area.element, 'pointermove', 15, 7);
149
+ pointer(area.element, 'pointermove', 15, 17);
150
+ pointer(area.element, 'pointerup', 15, 17);
151
+
152
+ expect(deltas).toEqual([
153
+ { x: 10, y: -6 },
154
+ { x: 0, y: 20 },
155
+ ]);
156
+ mouse.stop();
157
+ });
158
+
159
+ it('reports a short still touch as a tap, a drag not', () => {
160
+ const area = new TouchLookArea();
161
+ const taps: Point2[] = [];
162
+ area.tap$.subscribe(p => taps.push(p));
163
+
164
+ pointer(area.element, 'pointerdown', 10, 10);
165
+ pointer(area.element, 'pointerup', 11, 10);
166
+ pointer(area.element, 'pointerdown', 10, 10);
167
+ pointer(area.element, 'pointermove', 60, 10);
168
+ pointer(area.element, 'pointerup', 60, 10);
169
+
170
+ expect(taps).toEqual([{ x: 11, y: 10 }]);
171
+ });
172
+ });
@@ -0,0 +1,162 @@
1
+ import { DirectionInput, KeyboardInput } from '@gg-web-engine/core';
2
+ import { TouchButton } from '../src';
3
+ import { pointer } from './helpers';
4
+
5
+ describe('TouchButton', () => {
6
+ let keyboard: KeyboardInput;
7
+
8
+ beforeEach(() => {
9
+ keyboard = new KeyboardInput();
10
+ keyboard.start();
11
+ });
12
+
13
+ afterEach(() => keyboard.stop());
14
+
15
+ it('is pressed while a pointer is down on it', () => {
16
+ const button = new TouchButton();
17
+ const values: boolean[] = [];
18
+ button.pressed$.subscribe(v => values.push(v));
19
+
20
+ pointer(button.element, 'pointerdown');
21
+ expect(button.element.classList.contains('gg-mc-active')).toBe(true);
22
+ pointer(button.element, 'pointerup');
23
+
24
+ expect(values).toEqual([false, true, false]);
25
+ expect(button.element.classList.contains('gg-mc-active')).toBe(false);
26
+ });
27
+
28
+ it('follows only the pointer that pressed it', () => {
29
+ const button = new TouchButton();
30
+ pointer(button.element, 'pointerdown', 0, 0, 1);
31
+ pointer(button.element, 'pointerdown', 0, 0, 2);
32
+ pointer(button.element, 'pointerup', 0, 0, 2);
33
+ expect(button.pressed).toBe(true);
34
+ pointer(button.element, 'pointerup', 0, 0, 1);
35
+ expect(button.pressed).toBe(false);
36
+ });
37
+
38
+ it('keeps the pointer from the elements below it', () => {
39
+ const button = new TouchButton();
40
+ document.body.appendChild(button.element);
41
+ const seen = jest.fn();
42
+ window.addEventListener('pointerdown', seen);
43
+ window.addEventListener('pointermove', seen);
44
+
45
+ pointer(button.element, 'pointerdown');
46
+ pointer(button.element, 'pointermove', 5, 5);
47
+
48
+ expect(seen).not.toHaveBeenCalled();
49
+ window.removeEventListener('pointerdown', seen);
50
+ window.removeEventListener('pointermove', seen);
51
+ button.dispose();
52
+ });
53
+
54
+ it('flips on every press in toggle mode, and is released by reset', () => {
55
+ const button = new TouchButton({ mode: 'toggle' });
56
+ pointer(button.element, 'pointerdown');
57
+ pointer(button.element, 'pointerup');
58
+ expect(button.pressed).toBe(true);
59
+ pointer(button.element, 'pointerdown');
60
+ pointer(button.element, 'pointerup');
61
+ expect(button.pressed).toBe(false);
62
+
63
+ pointer(button.element, 'pointerdown');
64
+ pointer(button.element, 'pointerup');
65
+ button.reset();
66
+ expect(button.pressed).toBe(false);
67
+ });
68
+
69
+ it('releases what it holds when hidden', () => {
70
+ const button = new TouchButton({ mode: 'toggle' });
71
+ pointer(button.element, 'pointerdown');
72
+ pointer(button.element, 'pointerup');
73
+ button.visible = false;
74
+ expect(button.pressed).toBe(false);
75
+ expect(button.element.hidden).toBe(true);
76
+ button.visible = true;
77
+ expect(button.visible).toBe(true);
78
+ });
79
+
80
+ it('acts as a bound key', () => {
81
+ const values: boolean[] = [];
82
+ keyboard.bind('Space').subscribe(v => values.push(v));
83
+ const button = new TouchButton().bindKey(keyboard, 'Space');
84
+
85
+ pointer(button.element, 'pointerdown');
86
+ pointer(button.element, 'pointerup');
87
+
88
+ expect(values).toEqual([false, true, false]);
89
+ });
90
+
91
+ it('releases a bound key when disposed while pressed', () => {
92
+ const values: boolean[] = [];
93
+ keyboard.bind('Space').subscribe(v => values.push(v));
94
+ const button = new TouchButton().bindKey(keyboard, 'Space');
95
+
96
+ pointer(button.element, 'pointerdown');
97
+ button.dispose();
98
+
99
+ expect(values).toEqual([false, true, false]);
100
+ });
101
+
102
+ it('pushes a bound direction input while pressed', () => {
103
+ const input = new DirectionInput(keyboard, 'arrows');
104
+ input.start();
105
+ const button = new TouchButton().bindDirection(input, { x: -1 });
106
+
107
+ pointer(button.element, 'pointerdown');
108
+ expect(input.direction).toEqual({ x: -1, y: 0 });
109
+ pointer(button.element, 'pointerup');
110
+ expect(input.direction).toEqual({ x: 0, y: 0 });
111
+ input.stop();
112
+ });
113
+
114
+ it('calls onPress/onRelease, and carries its id and content', () => {
115
+ const press = jest.fn();
116
+ const release = jest.fn();
117
+ const button = new TouchButton({ id: 'fire', content: 'A', label: 'Fire' }).onPress(press).onRelease(release);
118
+ expect(button.element.classList.contains('gg-mc-id-fire')).toBe(true);
119
+ expect(button.element.textContent).toBe('A');
120
+ expect(button.element.getAttribute('aria-label')).toBe('Fire');
121
+
122
+ pointer(button.element, 'pointerdown');
123
+ expect(press).toHaveBeenCalledTimes(1);
124
+ expect(release).not.toHaveBeenCalled();
125
+ pointer(button.element, 'pointerup');
126
+ expect(release).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ it('delivers a change made by a subscriber to every binding in order', () => {
130
+ const button = new TouchButton();
131
+ const input = new DirectionInput();
132
+ input.start();
133
+ // the first binding hides the button as soon as it is pressed
134
+ button.onPress(() => (button.visible = false));
135
+ button.bindKey(keyboard, 'KeyF').bindDirection(input, { y: 1 });
136
+ const keys: boolean[] = [];
137
+ keyboard.bind('KeyF').subscribe(v => keys.push(v));
138
+ const values: boolean[] = [];
139
+ button.pressed$.subscribe(v => values.push(v));
140
+
141
+ pointer(button.element, 'pointerdown');
142
+
143
+ expect(values).toEqual([false, true, false]);
144
+ expect(button.pressed).toBe(false);
145
+ expect(keys).toEqual([false, true, false]);
146
+ expect(input.direction).toEqual({ x: 0, y: 0 });
147
+ input.stop();
148
+ });
149
+
150
+ it('releases every binding when disposed by a subscriber', () => {
151
+ const button = new TouchButton();
152
+ button.onPress(() => button.dispose());
153
+ button.bindKey(keyboard, 'KeyF');
154
+ const keys: boolean[] = [];
155
+ keyboard.bind('KeyF').subscribe(v => keys.push(v));
156
+
157
+ pointer(button.element, 'pointerdown');
158
+
159
+ expect(button.disposed).toBe(true);
160
+ expect(keys).toEqual([false, true, false]);
161
+ });
162
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": "./src/",
5
+ "outDir": "./dist/",
6
+ "rootDir": "./src/",
7
+ "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
8
+ "types": ["jest", "node"]
9
+ },
10
+ "include": ["src/*.ts", "src/**/*.ts"],
11
+ "references": [{ "path": "../core" }]
12
+ }