@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
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="../../documentation/assets/banner.png" width="100%" alt="GG Web Engine"/>
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ ## On-screen touch controls for [gg-web-engine](https://github.com/AndyGura/gg-web-engine)
6
+
7
+ `@gg-web-engine/mobile-controls` puts buttons, sticks and d-pads over the game canvas on phones and
8
+ tablets. It is plain DOM with no dependency besides the core, and a separate package on purpose: an
9
+ app that does not import it ships none of it.
10
+
11
+ ### Installation:
12
+ 1) make sure **@gg-web-engine/core** installed
13
+ 1) `npm install --save @gg-web-engine/mobile-controls`
14
+
15
+ ### Usage
16
+ ```typescript
17
+ import { MobileControls } from '@gg-web-engine/mobile-controls';
18
+
19
+ world.addEntity(new MobileControls());
20
+ ```
21
+
22
+ That is the whole setup. The overlay watches the world and shows the controls of whichever input
23
+ controller is active, swapping them as controllers are activated, deactivated, spawned and removed
24
+ (walking up to a car and driving off swaps the character controls for the car controls):
25
+
26
+ | Controller | Controls |
27
+ |---|---|
28
+ | `GgCarHandlingController` | steering, accelerate, brake, handbrake, gears when the driver has to shift |
29
+ | `CarHandlingController` (on its own) | steering, accelerate, brake |
30
+ | `PlayerCharacterController` | move stick, look by dragging, jump, run, crouch, switch view |
31
+ | `PlayerCharacterController2d` | left/right, jump, run, crouch |
32
+ | `FreeCameraController` | move stick, look by dragging, up, down, boost |
33
+
34
+ `OrbitCameraController` needs no controls, it follows one- and two-finger drags on the canvas itself.
35
+
36
+ By default the overlay exists only on a touch-first device (`enabled: 'auto'`); pass `enabled: true`
37
+ to try it with a mouse.
38
+
39
+ ### Choosing a control scheme
40
+ Every built-in layout has variants:
41
+
42
+ ```typescript
43
+ new MobileControls({
44
+ // 'buttons' (default) | 'stick' | 'tilt' - tilt steers by turning the device like a wheel
45
+ car: { steering: 'tilt', gears: false },
46
+ // movement: 'stick' (default, appears under the thumb) | 'dpad'
47
+ // look: 'drag' (default) | 'stick' (a second stick) | false
48
+ character: { movement: 'dpad', look: 'drag', lookSensitivity: 4 },
49
+ character2d: { movement: 'stick' },
50
+ freeCamera: false, // no touch controls for this controller
51
+ });
52
+ ```
53
+
54
+ ### Adjusting a layout
55
+ Each control of a layout has an id (`'accelerate'`, `'jump'`, `'move'`, ... - see the
56
+ `*LayoutControlId` types). The layout options take them to move, restyle, hide or add controls:
57
+
58
+ ```typescript
59
+ new MobileControls({
60
+ car: {
61
+ placements: { handbrake: { right: 28, bottom: 4 } }, // numbers are multiples of --gg-mc-unit
62
+ icons: { accelerate: '<img src="assets/pedal.svg">', brake: 'B' },
63
+ hide: ['gear-up', 'gear-down'],
64
+ extra: (controller, { world }) => [
65
+ new TouchButton({ id: 'horn', content: '📣', placement: { right: 4, top: 4 } })
66
+ .bindKey(world.keyboardInput, 'KeyH'),
67
+ ],
68
+ },
69
+ });
70
+ ```
71
+
72
+ Controls that belong to no controller stay on screen all the time:
73
+
74
+ ```typescript
75
+ const controls = new MobileControls();
76
+ controls.addControls(
77
+ new TouchButton({ id: 'pause', content: 'II', placement: { left: 4, top: 4 } }).onPress(() => togglePause()),
78
+ );
79
+ world.addEntity(controls);
80
+ ```
81
+
82
+ A single control is hidden and shown with `control.visible` (an action not available right now) and
83
+ moved with `control.place({...})`. `controls.visible = false` hides everything (a menu, a cutscene) and releases whatever was held.
84
+
85
+ ### Styling
86
+ The default stylesheet is driven by CSS custom properties on the overlay, so a class of your own
87
+ restyles everything, and `.gg-mc-id-<id>` reaches a single control:
88
+
89
+ ```css
90
+ .my-controls {
91
+ --gg-mc-unit: 9px; /* everything is sized in this */
92
+ --gg-mc-color: #ffd166;
93
+ --gg-mc-background: rgba(0, 0, 0, 0.4);
94
+ --gg-mc-border: rgba(255, 209, 102, 0.7);
95
+ --gg-mc-active-background: rgba(255, 209, 102, 0.5);
96
+ --gg-mc-opacity: 1;
97
+ }
98
+ .my-controls .gg-mc-id-jump { border-radius: 20%; }
99
+ ```
100
+ ```typescript
101
+ new MobileControls({ className: 'my-controls' });
102
+ ```
103
+
104
+ `injectStyles: false` drops the default stylesheet altogether for a fully custom look.
105
+
106
+ ### Your own layout, your own controller
107
+ A layout is a function from a controller to controls. Register one for any entity class - your own
108
+ controller, or a built-in one to replace its layout wholesale:
109
+
110
+ ```typescript
111
+ controls.registerLayout(TurretController, (turret, { world }) => [
112
+ new TouchLookArea().bindMouse(turret.mouseInput, 3),
113
+ new TouchStick({ id: 'aim', placement: { left: 4, bottom: 3 } }).bindDirection(turret.directionsInput),
114
+ new TouchButton({ id: 'fire', content: 'FIRE', placement: { right: 4, bottom: 4, width: 12, height: 12 } })
115
+ .onPress(() => turret.fire()),
116
+ ]);
117
+ ```
118
+
119
+ The controls bind to what the engine already has, so nothing in the controller needs to know about
120
+ touch:
121
+
122
+ | Binding | Effect |
123
+ |---|---|
124
+ | `button.bindKey(keyboard, 'Space')` | the button is that key (`KeyboardInput.emulateKeyDown/Up`) |
125
+ | `button.bindDirection(input, { x: -1 })` | pushes a `DirectionInput` while pressed |
126
+ | `stick.bindDirection(input)` | analog direction into a `DirectionInput` |
127
+ | `stick.bindKeys(keyboard, { up: 'KeyW', ... })` | holds keys past a threshold |
128
+ | `stick.bindLook(mouse)` / `lookArea.bindMouse(mouse)` | turns the view like the mouse does (`MouseInput.emulateMove`) |
129
+ | `button.pressed$`, `stick.value$`, `lookArea.delta$`/`tap$` | plain observables for anything else |
130
+
131
+ `TouchButton`, `TouchStick`, `TouchDPad` and `TouchLookArea` do not need the overlay: each owns a DOM
132
+ `element` you can append anywhere in your own UI. `TiltInput` is likewise usable alone.
133
+
134
+ ### Where the overlay goes
135
+ By default it is added to `document.body` and covers the viewport, which suits a game filling the
136
+ page. For a canvas that takes a part of the page, or one shown through the Fullscreen API, wrap the
137
+ canvas in a positioned element and pass it as `container`: the overlay then covers exactly that
138
+ element and goes fullscreen with it.
139
+
140
+ ```typescript
141
+ new MobileControls({ container: document.getElementById('game-wrapper') });
142
+ ```
143
+
144
+ ### Notes
145
+ - A look area covers the canvas, so taps on the canvas itself do not reach the page while a layout
146
+ with `look: 'drag'` is shown; use the area's `tap$`, or `look: false`.
147
+ - Tilt steering works on pages served over https (or from localhost) only - a phone opening a dev
148
+ server by its LAN address over plain http gets no orientation data.
149
+ - Tilt steering needs the device orientation permission on iOS. `TiltInput` asks for it on the first
150
+ tap after it starts and reports the outcome through `permission$`.
151
+ - A layout is built once per activation of its controller. After changing something it was built
152
+ from (e.g. giving the car controller a car with another gearbox), call `controls.refresh()`.
@@ -0,0 +1,38 @@
1
+ import { Observable } from 'rxjs';
2
+ import { DirectionInput, KeyboardInput, MouseInput, Point2 } from '@gg-web-engine/core';
3
+ import { TouchControl, TouchControlOptions } from './touch-control';
4
+ /** The key codes a `TouchAxisControl` presses for each direction - see `bindKeys`. */
5
+ export type AxisKeys = {
6
+ up?: string;
7
+ down?: string;
8
+ left?: string;
9
+ right?: string;
10
+ };
11
+ /**
12
+ * The base of the controls that point in a direction (`TouchStick`, `TouchDPad`). `value$` is a
13
+ * vector with `x` from -1 (left) to 1 (right) and `y` from -1 (down) to 1 (up), never longer than 1 -
14
+ * the same convention as `DirectionInput.direction$`.
15
+ */
16
+ export declare abstract class TouchAxisControl extends TouchControl {
17
+ private readonly _value$;
18
+ /** Emits the current value on subscription and then every change; completes on `dispose`. */
19
+ get value$(): Observable<Point2>;
20
+ get value(): Point2;
21
+ protected constructor(kind: string, options: TouchControlOptions);
22
+ protected setValue(value: Point2): void;
23
+ /**
24
+ * Feeds the control into `input` as an analog direction, which every built-in controller moving by
25
+ * direction keys (car, character, free camera) follows proportionally.
26
+ */
27
+ bindDirection(input: DirectionInput): this;
28
+ /**
29
+ * Makes the control hold a key of `keyboard` down per direction while it points that way further
30
+ * than `threshold` - for anything that only understands keys.
31
+ */
32
+ bindKeys(keyboard: KeyboardInput, keys: AxisKeys, threshold?: number): this;
33
+ /**
34
+ * Makes the control turn a view the way a mouse does: while it is deflected, `mouse` reports a
35
+ * continuous movement of up to `speed` pixels per second in that direction.
36
+ */
37
+ bindLook(mouse: MouseInput, speed?: number): this;
38
+ }
@@ -0,0 +1,91 @@
1
+ import { BehaviorSubject, takeUntil } from 'rxjs';
2
+ import { distinctUntilChanged } from 'rxjs/operators';
3
+ import { Pnt2 } from '@gg-web-engine/core';
4
+ import { TouchControl } from './touch-control';
5
+ /**
6
+ * The base of the controls that point in a direction (`TouchStick`, `TouchDPad`). `value$` is a
7
+ * vector with `x` from -1 (left) to 1 (right) and `y` from -1 (down) to 1 (up), never longer than 1 -
8
+ * the same convention as `DirectionInput.direction$`.
9
+ */
10
+ export class TouchAxisControl extends TouchControl {
11
+ /** Emits the current value on subscription and then every change; completes on `dispose`. */
12
+ get value$() {
13
+ return this._value$.pipe(distinctUntilChanged((a, b) => a.x === b.x && a.y === b.y), takeUntil(this.disposed$));
14
+ }
15
+ get value() {
16
+ return this._value$.getValue();
17
+ }
18
+ constructor(kind, options) {
19
+ super(kind, options);
20
+ this._value$ = new BehaviorSubject(Pnt2.O);
21
+ }
22
+ setValue(value) {
23
+ if (!this.disposed) {
24
+ this._value$.next(value);
25
+ }
26
+ }
27
+ /**
28
+ * Feeds the control into `input` as an analog direction, which every built-in controller moving by
29
+ * direction keys (car, character, free camera) follows proportionally.
30
+ */
31
+ bindDirection(input) {
32
+ this.value$.subscribe(v => input.setAnalogDirection(this, v.x === 0 && v.y === 0 ? null : v));
33
+ return this;
34
+ }
35
+ /**
36
+ * Makes the control hold a key of `keyboard` down per direction while it points that way further
37
+ * than `threshold` - for anything that only understands keys.
38
+ */
39
+ bindKeys(keyboard, keys, threshold = 0.5) {
40
+ const held = {};
41
+ const set = (code, down) => {
42
+ if (!code || !!held[code] === down) {
43
+ return;
44
+ }
45
+ held[code] = down;
46
+ down ? keyboard.emulateKeyDown(code) : keyboard.emulateKeyUp(code);
47
+ };
48
+ this.value$.subscribe(v => {
49
+ set(keys.up, v.y > threshold);
50
+ set(keys.down, v.y < -threshold);
51
+ set(keys.left, v.x < -threshold);
52
+ set(keys.right, v.x > threshold);
53
+ });
54
+ return this;
55
+ }
56
+ /**
57
+ * Makes the control turn a view the way a mouse does: while it is deflected, `mouse` reports a
58
+ * continuous movement of up to `speed` pixels per second in that direction.
59
+ */
60
+ bindLook(mouse, speed = 900) {
61
+ let frame = null;
62
+ let last = 0;
63
+ const step = (time) => {
64
+ frame = null;
65
+ const { x, y } = this.value;
66
+ if (this.disposed || (x === 0 && y === 0)) {
67
+ return;
68
+ }
69
+ // the first frame's timestamp is its start time, which may precede the moment `last` was taken
70
+ const dt = Math.max(0, Math.min(100, time - last)) / 1000;
71
+ last = time;
72
+ // the pointer's y grows downwards, the control's upwards
73
+ mouse.emulateMove({ x: x * speed * dt, y: -y * speed * dt });
74
+ frame = requestAnimationFrame(step);
75
+ };
76
+ this.value$.subscribe({
77
+ next: v => {
78
+ if (frame === null && (v.x !== 0 || v.y !== 0)) {
79
+ last = performance.now();
80
+ frame = requestAnimationFrame(step);
81
+ }
82
+ },
83
+ complete: () => {
84
+ if (frame !== null) {
85
+ cancelAnimationFrame(frame);
86
+ }
87
+ },
88
+ });
89
+ return this;
90
+ }
91
+ }
@@ -0,0 +1,49 @@
1
+ import { Observable } from 'rxjs';
2
+ import { AnalogDirection, DirectionInput, KeyboardInput } from '@gg-web-engine/core';
3
+ import { TouchControl, TouchControlOptions } from './touch-control';
4
+ export type TouchButtonOptions = TouchControlOptions & {
5
+ /** What the button shows: markup (an inline SVG, plain text) or a DOM node. */
6
+ content?: string | Node;
7
+ /**
8
+ * `'hold'` (default): pressed while a finger is on it. `'toggle'`: every tap flips it, for an
9
+ * action that would otherwise need a finger parked on the button (sprint, crouch).
10
+ */
11
+ mode?: 'hold' | 'toggle';
12
+ };
13
+ /**
14
+ * An on-screen button. Read it through `pressed$`, or bind it to what a key already does with
15
+ * `bindKey`, so anything an app maps to its keyboard gets a touch button without further code.
16
+ */
17
+ export declare class TouchButton extends TouchControl {
18
+ readonly mode: 'hold' | 'toggle';
19
+ private readonly _pressed$;
20
+ private _pressed;
21
+ private emitting;
22
+ private disposeRequested;
23
+ /** Emits the current state on subscription and then every change; completes on `dispose`. */
24
+ get pressed$(): Observable<boolean>;
25
+ get pressed(): boolean;
26
+ /** Settable, e.g. to bring a toggle button in line with a state that changed by other means. */
27
+ set pressed(value: boolean);
28
+ constructor(options?: TouchButtonOptions);
29
+ setContent(content: string | Node): void;
30
+ /**
31
+ * Makes the button act as the key `code` of `keyboard`: pressing it is that key going down,
32
+ * releasing it the key going up.
33
+ */
34
+ bindKey(keyboard: KeyboardInput, code: string): this;
35
+ /**
36
+ * Makes the button push `input` in a direction while pressed, e.g. `{ x: -1 }` for "left".
37
+ */
38
+ bindDirection(input: DirectionInput, value: AnalogDirection): this;
39
+ /** Calls `callback` every time the button becomes pressed. */
40
+ onPress(callback: () => void): this;
41
+ /** Calls `callback` every time the button stops being pressed. */
42
+ onRelease(callback: () => void): this;
43
+ reset(): void;
44
+ dispose(): void;
45
+ /** `pressed$` without the value it replays on subscription. */
46
+ private get changes$();
47
+ protected onPointerStart(): void;
48
+ protected onPointerEnd(): void;
49
+ }
@@ -0,0 +1,111 @@
1
+ import { BehaviorSubject, skip, takeUntil } from 'rxjs';
2
+ import { distinctUntilChanged, filter } from 'rxjs/operators';
3
+ import { TouchControl } from './touch-control';
4
+ /**
5
+ * An on-screen button. Read it through `pressed$`, or bind it to what a key already does with
6
+ * `bindKey`, so anything an app maps to its keyboard gets a touch button without further code.
7
+ */
8
+ export class TouchButton extends TouchControl {
9
+ /** Emits the current state on subscription and then every change; completes on `dispose`. */
10
+ get pressed$() {
11
+ return this._pressed$.pipe(distinctUntilChanged(), takeUntil(this.disposed$));
12
+ }
13
+ get pressed() {
14
+ return this._pressed;
15
+ }
16
+ /** Settable, e.g. to bring a toggle button in line with a state that changed by other means. */
17
+ set pressed(value) {
18
+ if (this.disposed || value === this._pressed) {
19
+ return;
20
+ }
21
+ this._pressed = value;
22
+ this.element.classList.toggle('gg-mc-active', value);
23
+ this.element.setAttribute('aria-pressed', `${value}`);
24
+ if (this.emitting) {
25
+ // set from within a subscriber: the loop below delivers it once every subscriber has seen
26
+ // the change being emitted, so all of them get the changes in the same order
27
+ return;
28
+ }
29
+ this.emitting = true;
30
+ try {
31
+ while (this._pressed$.getValue() !== this._pressed) {
32
+ this._pressed$.next(this._pressed);
33
+ }
34
+ }
35
+ finally {
36
+ this.emitting = false;
37
+ }
38
+ if (this.disposeRequested) {
39
+ super.dispose();
40
+ }
41
+ }
42
+ constructor(options = {}) {
43
+ var _a;
44
+ super('button', options);
45
+ this._pressed$ = new BehaviorSubject(false);
46
+ this._pressed = false;
47
+ this.emitting = false;
48
+ this.disposeRequested = false;
49
+ this.mode = options.mode || 'hold';
50
+ this.element.setAttribute('role', 'button');
51
+ this.setContent((_a = options.content) !== null && _a !== void 0 ? _a : '');
52
+ }
53
+ setContent(content) {
54
+ if (typeof content === 'string') {
55
+ this.element.innerHTML = content;
56
+ }
57
+ else {
58
+ this.element.replaceChildren(content);
59
+ }
60
+ }
61
+ /**
62
+ * Makes the button act as the key `code` of `keyboard`: pressing it is that key going down,
63
+ * releasing it the key going up.
64
+ */
65
+ bindKey(keyboard, code) {
66
+ this.changes$.subscribe(pressed => (pressed ? keyboard.emulateKeyDown(code) : keyboard.emulateKeyUp(code)));
67
+ return this;
68
+ }
69
+ /**
70
+ * Makes the button push `input` in a direction while pressed, e.g. `{ x: -1 }` for "left".
71
+ */
72
+ bindDirection(input, value) {
73
+ this.changes$.subscribe(pressed => input.setAnalogDirection(this, pressed ? value : null));
74
+ return this;
75
+ }
76
+ /** Calls `callback` every time the button becomes pressed. */
77
+ onPress(callback) {
78
+ this.changes$.pipe(filter(pressed => pressed)).subscribe(() => callback());
79
+ return this;
80
+ }
81
+ /** Calls `callback` every time the button stops being pressed. */
82
+ onRelease(callback) {
83
+ this.changes$.pipe(filter(pressed => !pressed)).subscribe(() => callback());
84
+ return this;
85
+ }
86
+ reset() {
87
+ super.reset();
88
+ this.pressed = false;
89
+ }
90
+ dispose() {
91
+ if (this.emitting) {
92
+ // disposed from within a subscriber: the release has to reach every subscriber first
93
+ this.disposeRequested = true;
94
+ this.reset();
95
+ return;
96
+ }
97
+ super.dispose();
98
+ }
99
+ /** `pressed$` without the value it replays on subscription. */
100
+ get changes$() {
101
+ return this.pressed$.pipe(skip(1));
102
+ }
103
+ onPointerStart() {
104
+ this.pressed = this.mode === 'toggle' ? !this.pressed : true;
105
+ }
106
+ onPointerEnd() {
107
+ if (this.mode === 'hold') {
108
+ this.pressed = false;
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,76 @@
1
+ import { Subject } from 'rxjs';
2
+ /**
3
+ * Where a control sits inside the overlay and how big it is. A number is a multiple of the overlay's
4
+ * `--gg-mc-unit` (an edge offset also keeps clear of the device's safe-area inset on that side); a
5
+ * string is used as a CSS value as is. Leave `width`/`height` out to keep the size the stylesheet
6
+ * gives that kind of control.
7
+ */
8
+ export type ControlPlacement = {
9
+ left?: number | string;
10
+ right?: number | string;
11
+ top?: number | string;
12
+ bottom?: number | string;
13
+ width?: number | string;
14
+ height?: number | string;
15
+ };
16
+ export type TouchControlOptions = {
17
+ /**
18
+ * Identifies the control within its layout. Becomes the element's `gg-mc-id-<id>` class and
19
+ * `data-gg-mc` attribute, for styling one control from CSS.
20
+ */
21
+ id?: string;
22
+ placement?: ControlPlacement;
23
+ /** Extra CSS class names for the element, space-separated. */
24
+ className?: string;
25
+ /** Accessible name of the control. */
26
+ label?: string;
27
+ };
28
+ /**
29
+ * Sets a control element's inline position/size from a `ControlPlacement`.
30
+ */
31
+ export declare function applyControlPlacement(element: HTMLElement, placement: ControlPlacement): void;
32
+ /**
33
+ * The base of every on-screen control: owns one DOM element and follows one pointer on it at a time.
34
+ * A control is plain DOM with no dependency on a world, so it works inside a `MobileControls` overlay
35
+ * and equally in an app's own markup - append `element` anywhere.
36
+ *
37
+ * A pointer that goes down on the control is captured by it and never reaches the elements below
38
+ * (the game canvas, a `MouseInput` listening on the window).
39
+ */
40
+ export declare abstract class TouchControl {
41
+ readonly element: HTMLElement;
42
+ readonly id: string | undefined;
43
+ protected readonly disposed$: Subject<void>;
44
+ private activePointerId;
45
+ private _disposed;
46
+ get disposed(): boolean;
47
+ /** Whether a pointer is currently down on the control. */
48
+ get touched(): boolean;
49
+ get visible(): boolean;
50
+ /**
51
+ * Takes the control off the screen (and back) without disposing it, for an action that is not
52
+ * available at the moment. Hiding releases whatever the control holds.
53
+ */
54
+ set visible(value: boolean);
55
+ /** Moves/resizes the control; edges and sizes left out of `placement` stay as they are. */
56
+ place(placement: ControlPlacement): void;
57
+ protected constructor(kind: string, options: TouchControlOptions);
58
+ /**
59
+ * Returns the control to its untouched state, releasing whatever it holds (a pressed button, a
60
+ * deflected stick). A latched toggle button is released too.
61
+ */
62
+ reset(): void;
63
+ /**
64
+ * Resets the control, completes its observables (ending every binding made through it) and removes
65
+ * its element from the document.
66
+ */
67
+ dispose(): void;
68
+ protected abstract onPointerStart(event: PointerEvent): void;
69
+ protected onPointerMove(event: PointerEvent): void;
70
+ /** `event` is `null` when the control was reset rather than released by the pointer. */
71
+ protected abstract onPointerEnd(event: PointerEvent | null): void;
72
+ private handlePointerDown;
73
+ private handlePointerMove;
74
+ private handlePointerEnd;
75
+ private releaseCapture;
76
+ }
@@ -0,0 +1,151 @@
1
+ import { Subject } from 'rxjs';
2
+ const EDGES = ['left', 'right', 'top', 'bottom'];
3
+ /**
4
+ * Sets a control element's inline position/size from a `ControlPlacement`.
5
+ */
6
+ export function applyControlPlacement(element, placement) {
7
+ for (const edge of EDGES) {
8
+ const value = placement[edge];
9
+ if (value !== undefined) {
10
+ element.style.setProperty(edge, typeof value === 'number' ? `calc(var(--gg-mc-unit) * ${value} + env(safe-area-inset-${edge}, 0px))` : value);
11
+ }
12
+ }
13
+ for (const dimension of ['width', 'height']) {
14
+ const value = placement[dimension];
15
+ if (value !== undefined) {
16
+ element.style.setProperty(dimension, typeof value === 'number' ? `calc(var(--gg-mc-unit) * ${value})` : value);
17
+ }
18
+ }
19
+ }
20
+ /**
21
+ * The base of every on-screen control: owns one DOM element and follows one pointer on it at a time.
22
+ * A control is plain DOM with no dependency on a world, so it works inside a `MobileControls` overlay
23
+ * and equally in an app's own markup - append `element` anywhere.
24
+ *
25
+ * A pointer that goes down on the control is captured by it and never reaches the elements below
26
+ * (the game canvas, a `MouseInput` listening on the window).
27
+ */
28
+ export class TouchControl {
29
+ get disposed() {
30
+ return this._disposed;
31
+ }
32
+ /** Whether a pointer is currently down on the control. */
33
+ get touched() {
34
+ return this.activePointerId !== null;
35
+ }
36
+ get visible() {
37
+ return !this.element.hidden;
38
+ }
39
+ /**
40
+ * Takes the control off the screen (and back) without disposing it, for an action that is not
41
+ * available at the moment. Hiding releases whatever the control holds.
42
+ */
43
+ set visible(value) {
44
+ if (!value) {
45
+ this.reset();
46
+ }
47
+ this.element.hidden = !value;
48
+ }
49
+ /** Moves/resizes the control; edges and sizes left out of `placement` stay as they are. */
50
+ place(placement) {
51
+ applyControlPlacement(this.element, placement);
52
+ }
53
+ constructor(kind, options) {
54
+ this.disposed$ = new Subject();
55
+ this.activePointerId = null;
56
+ this._disposed = false;
57
+ this.id = options.id;
58
+ this.element = document.createElement('div');
59
+ this.element.className = `gg-mc-control gg-mc-${kind}`;
60
+ if (options.id) {
61
+ this.element.classList.add(`gg-mc-id-${options.id}`);
62
+ this.element.dataset.ggMc = options.id;
63
+ }
64
+ if (options.className) {
65
+ this.element.classList.add(...options.className.split(/\s+/).filter(x => !!x));
66
+ }
67
+ if (options.label) {
68
+ this.element.setAttribute('aria-label', options.label);
69
+ }
70
+ if (options.placement) {
71
+ applyControlPlacement(this.element, options.placement);
72
+ }
73
+ this.handlePointerDown = this.handlePointerDown.bind(this);
74
+ this.handlePointerMove = this.handlePointerMove.bind(this);
75
+ this.handlePointerEnd = this.handlePointerEnd.bind(this);
76
+ this.element.addEventListener('pointerdown', this.handlePointerDown);
77
+ this.element.addEventListener('pointermove', this.handlePointerMove);
78
+ this.element.addEventListener('pointerup', this.handlePointerEnd);
79
+ this.element.addEventListener('pointercancel', this.handlePointerEnd);
80
+ this.element.addEventListener('lostpointercapture', this.handlePointerEnd);
81
+ this.element.addEventListener('contextmenu', e => e.preventDefault());
82
+ // stops the long-press text selection/magnifier of iOS, which `touch-action` does not cover
83
+ this.element.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
84
+ }
85
+ /**
86
+ * Returns the control to its untouched state, releasing whatever it holds (a pressed button, a
87
+ * deflected stick). A latched toggle button is released too.
88
+ */
89
+ reset() {
90
+ if (this.activePointerId !== null) {
91
+ this.releaseCapture(this.activePointerId);
92
+ this.activePointerId = null;
93
+ this.onPointerEnd(null);
94
+ }
95
+ }
96
+ /**
97
+ * Resets the control, completes its observables (ending every binding made through it) and removes
98
+ * its element from the document.
99
+ */
100
+ dispose() {
101
+ if (this._disposed) {
102
+ return;
103
+ }
104
+ this.reset();
105
+ this._disposed = true;
106
+ this.disposed$.next();
107
+ this.disposed$.complete();
108
+ this.element.remove();
109
+ }
110
+ onPointerMove(event) { }
111
+ handlePointerDown(event) {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ if (this.activePointerId !== null || (event.pointerType === 'mouse' && event.button !== 0)) {
115
+ return;
116
+ }
117
+ this.activePointerId = event.pointerId;
118
+ try {
119
+ this.element.setPointerCapture(event.pointerId);
120
+ }
121
+ catch (err) {
122
+ // not capturable (a synthetic event, a pointer already gone): the control still works while
123
+ // the pointer stays over it
124
+ }
125
+ this.onPointerStart(event);
126
+ }
127
+ handlePointerMove(event) {
128
+ event.stopPropagation();
129
+ if (event.pointerId === this.activePointerId) {
130
+ event.preventDefault();
131
+ this.onPointerMove(event);
132
+ }
133
+ }
134
+ handlePointerEnd(event) {
135
+ event.stopPropagation();
136
+ if (event.pointerId !== this.activePointerId) {
137
+ return;
138
+ }
139
+ this.activePointerId = null;
140
+ this.releaseCapture(event.pointerId);
141
+ this.onPointerEnd(event);
142
+ }
143
+ releaseCapture(pointerId) {
144
+ try {
145
+ this.element.releasePointerCapture(pointerId);
146
+ }
147
+ catch (err) {
148
+ // was not captured
149
+ }
150
+ }
151
+ }