@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,274 @@
1
+ import { takeUntil } from 'rxjs';
2
+ import { CarHandlingController, FreeCameraController, GgCarHandlingController, IEntity, PlayerCharacterController, PlayerCharacterController2d, TickOrder, } from '@gg-web-engine/core';
3
+ import { TouchControl } from './controls/touch-control';
4
+ import { carLayout } from './layouts/car.layout';
5
+ import { character2dLayout } from './layouts/character-2d.layout';
6
+ import { characterLayout } from './layouts/character.layout';
7
+ import { freeCameraLayout } from './layouts/free-camera.layout';
8
+ import { injectMobileControlsStyles } from './styles';
9
+ /**
10
+ * Whether the device is operated by touch first (a phone, a tablet) - what `enabled: 'auto'` goes by.
11
+ * A laptop that merely has a touch screen next to its mouse does not count.
12
+ */
13
+ export function isTouchFirstDevice() {
14
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
15
+ return false;
16
+ }
17
+ if (typeof window.matchMedia === 'function') {
18
+ return window.matchMedia('(pointer: coarse)').matches;
19
+ }
20
+ return navigator.maxTouchPoints > 0;
21
+ }
22
+ const DEFAULT_OPTIONS = {
23
+ enabled: 'auto',
24
+ container: null,
25
+ className: '',
26
+ injectStyles: true,
27
+ car: {},
28
+ character: {},
29
+ character2d: {},
30
+ freeCamera: {},
31
+ };
32
+ /**
33
+ * An overlay of on-screen touch controls over the game canvas. Add it to a world once, and it shows
34
+ * the controls matching whichever input controller is active in that world at the moment, swapping
35
+ * them as controllers are activated, deactivated, spawned and removed:
36
+ *
37
+ * ```ts
38
+ * world.addEntity(new MobileControls());
39
+ * ```
40
+ *
41
+ * Out of the box it knows the car controllers (`GgCarHandlingController`,
42
+ * `CarHandlingController`), `PlayerCharacterController`, `PlayerCharacterController2d` and
43
+ * `FreeCameraController`. Each of those layouts is adjusted through
44
+ * its options (see `MobileControlsOptions`), replaced by `registerLayout`, and any other controller
45
+ * class - including an app's own - gets a layout the same way. Controls that belong to no controller
46
+ * (pause, a menu button) are added with `addControls`.
47
+ */
48
+ export class MobileControls extends IEntity {
49
+ get enabled() {
50
+ return this._enabled;
51
+ }
52
+ /** See `MobileControlsOptions.enabled`. */
53
+ set enabled(value) {
54
+ this._enabled = value;
55
+ this.sync();
56
+ }
57
+ get visible() {
58
+ return this._visible;
59
+ }
60
+ /**
61
+ * Hides the overlay without disabling it, for the moments touch controls are in the way (a menu,
62
+ * a cutscene). Everything held is released.
63
+ */
64
+ set visible(value) {
65
+ this._visible = value;
66
+ this.sync();
67
+ }
68
+ /** Whether the overlay is on the page right now. */
69
+ get shown() {
70
+ return this.element.isConnected;
71
+ }
72
+ /** The controllers that currently have their controls on screen. */
73
+ get activeControllers() {
74
+ return [...this.layouts.keys()];
75
+ }
76
+ constructor(options = {}) {
77
+ super();
78
+ this.tickOrder = TickOrder.INPUT_CONTROLLERS;
79
+ this.registrations = [];
80
+ this.tracked = new Set();
81
+ this.layouts = new Map();
82
+ this.ownControls = new Set();
83
+ this._visible = true;
84
+ this.options = Object.assign(Object.assign({}, DEFAULT_OPTIONS), options);
85
+ this._enabled = this.options.enabled;
86
+ this.element = document.createElement('div');
87
+ this.element.className = 'gg-mc';
88
+ if (this.options.container) {
89
+ this.element.classList.add('gg-mc--contained');
90
+ }
91
+ if (this.options.className) {
92
+ this.element.classList.add(...this.options.className.split(/\s+/).filter(x => !!x));
93
+ }
94
+ this.releaseAll = this.releaseAll.bind(this);
95
+ if (this.options.car) {
96
+ const layout = carLayout(this.options.car);
97
+ this.registerLayout(CarHandlingController, layout);
98
+ this.registerLayout(GgCarHandlingController, layout);
99
+ }
100
+ if (this.options.character) {
101
+ this.registerLayout(PlayerCharacterController, characterLayout(this.options.character));
102
+ }
103
+ if (this.options.character2d) {
104
+ this.registerLayout(PlayerCharacterController2d, character2dLayout(this.options.character2d));
105
+ }
106
+ if (this.options.freeCamera) {
107
+ this.registerLayout(FreeCameraController, freeCameraLayout(this.options.freeCamera));
108
+ }
109
+ }
110
+ /**
111
+ * Sets the layout shown while a controller of `controllerClass` (or of a subclass) is active,
112
+ * replacing a layout registered for the same class before; `null` leaves that class without one.
113
+ * Of several registered classes a controller is an instance of, the one registered last wins.
114
+ */
115
+ registerLayout(controllerClass, factory) {
116
+ const index = this.registrations.findIndex(r => r.controllerClass === controllerClass);
117
+ if (index >= 0) {
118
+ this.registrations.splice(index, 1);
119
+ }
120
+ if (factory) {
121
+ this.registrations.push({ controllerClass, factory });
122
+ }
123
+ if (this.world) {
124
+ this.rescan();
125
+ }
126
+ }
127
+ /**
128
+ * Adds controls that stay on screen whichever controller is active, on top of the layouts.
129
+ */
130
+ addControls(...controls) {
131
+ for (const control of controls) {
132
+ this.ownControls.add(control);
133
+ this.element.appendChild(control.element);
134
+ }
135
+ }
136
+ /** Removes controls added by `addControls` and disposes them. */
137
+ removeControls(...controls) {
138
+ for (const control of controls) {
139
+ if (this.ownControls.delete(control)) {
140
+ control.dispose();
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Builds the layout of `controller` anew - after something its layout was built from has changed
146
+ * (the car it drives, an option), since a layout is otherwise built once per activation.
147
+ */
148
+ refresh(controller) {
149
+ for (const key of controller ? [controller] : [...this.layouts.keys()]) {
150
+ this.disposeLayout(key);
151
+ }
152
+ this.sync();
153
+ }
154
+ onSpawned(world) {
155
+ super.onSpawned(world);
156
+ world.entityAdded$.pipe(takeUntil(this._onRemoved$)).subscribe(entity => {
157
+ if (this.findFactory(entity)) {
158
+ this.tracked.add(entity);
159
+ this.sync();
160
+ }
161
+ });
162
+ world.entityRemoved$.pipe(takeUntil(this._onRemoved$)).subscribe(entity => {
163
+ if (this.tracked.delete(entity)) {
164
+ this.sync();
165
+ }
166
+ });
167
+ // a world whose input is switched off (a screen covering the game) takes its controls off the
168
+ // screen; it may be paused at that moment, so this can't wait for the next tick
169
+ world.inputEnabled$.pipe(takeUntil(this._onRemoved$)).subscribe(() => this.sync());
170
+ // `active` has no change notification, so it is polled
171
+ this.tick$.pipe(takeUntil(this._onRemoved$)).subscribe(() => this.sync());
172
+ window.addEventListener('blur', this.releaseAll);
173
+ this.rescan();
174
+ }
175
+ onRemoved() {
176
+ super.onRemoved();
177
+ window.removeEventListener('blur', this.releaseAll);
178
+ this.tracked.clear();
179
+ this.sync();
180
+ }
181
+ dispose() {
182
+ super.dispose();
183
+ for (const control of this.ownControls) {
184
+ control.dispose();
185
+ }
186
+ this.ownControls.clear();
187
+ }
188
+ /** Returns every control on screen to its untouched state. */
189
+ releaseAll() {
190
+ for (const items of this.layouts.values()) {
191
+ for (const item of items) {
192
+ if (item instanceof TouchControl) {
193
+ item.reset();
194
+ }
195
+ }
196
+ }
197
+ for (const control of this.ownControls) {
198
+ control.reset();
199
+ }
200
+ }
201
+ findFactory(entity) {
202
+ for (let i = this.registrations.length - 1; i >= 0; i--) {
203
+ if (entity instanceof this.registrations[i].controllerClass) {
204
+ return this.registrations[i].factory;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+ rescan() {
210
+ var _a;
211
+ this.tracked.clear();
212
+ const visit = (entity) => {
213
+ if (this.findFactory(entity)) {
214
+ this.tracked.add(entity);
215
+ }
216
+ entity.children.forEach(visit);
217
+ };
218
+ (((_a = this.world) === null || _a === void 0 ? void 0 : _a.children) || []).forEach(visit);
219
+ for (const controller of [...this.layouts.keys()]) {
220
+ this.disposeLayout(controller);
221
+ }
222
+ this.sync();
223
+ }
224
+ sync() {
225
+ const world = this.world;
226
+ const show = !!world &&
227
+ this._visible &&
228
+ world.inputEnabled !== false &&
229
+ (this._enabled === 'auto' ? isTouchFirstDevice() : this._enabled);
230
+ if (show !== this.shown) {
231
+ if (show) {
232
+ if (this.options.injectStyles) {
233
+ injectMobileControlsStyles();
234
+ }
235
+ (this.options.container || document.body).appendChild(this.element);
236
+ }
237
+ else {
238
+ this.releaseAll();
239
+ this.element.remove();
240
+ }
241
+ }
242
+ for (const controller of [...this.layouts.keys()]) {
243
+ if (!show || !this.tracked.has(controller) || !controller.active) {
244
+ this.disposeLayout(controller);
245
+ }
246
+ }
247
+ if (!show) {
248
+ return;
249
+ }
250
+ for (const controller of this.tracked) {
251
+ if (controller.active && !this.layouts.has(controller)) {
252
+ const items = this.findFactory(controller)(controller, { world: world, controls: this });
253
+ this.layouts.set(controller, items);
254
+ // below the controls added by `addControls`, which stay on top
255
+ const before = this.ownControls.size > 0 ? [...this.ownControls][0].element : null;
256
+ for (const item of items) {
257
+ if (item instanceof TouchControl) {
258
+ this.element.insertBefore(item.element, before);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ disposeLayout(controller) {
265
+ const items = this.layouts.get(controller);
266
+ if (items) {
267
+ this.layouts.delete(controller);
268
+ for (const item of items) {
269
+ item.dispose();
270
+ }
271
+ }
272
+ }
273
+ }
274
+ MobileControls.entityTypeName = 'MobileControls';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The id of the `<style>` element `injectMobileControlsStyles` adds to the document head.
3
+ */
4
+ export declare const MOBILE_CONTROLS_STYLE_ID = "gg-mobile-controls-styles";
5
+ /**
6
+ * The default stylesheet of the overlay. `--gg-mc-unit` follows the viewport so that the built-in
7
+ * layouts, which are up to 55 units wide, fit a phone held upright as well as on its side (at least
8
+ * 58 units across). Everything is sized in `--gg-mc-unit` and colored through
9
+ * the other `--gg-mc-*` custom properties declared on `.gg-mc`, so an app restyles the overlay by
10
+ * overriding those on its own class, and a single control through its `.gg-mc-id-*` class.
11
+ */
12
+ export declare const MOBILE_CONTROLS_CSS = "\n.gg-mc {\n --gg-mc-unit: clamp(5px, min(2vmin, 1.7vw), 10px);\n --gg-mc-color: #fff;\n --gg-mc-background: rgba(18, 22, 30, 0.3);\n --gg-mc-border: rgba(255, 255, 255, 0.6);\n --gg-mc-active-background: rgba(255, 255, 255, 0.4);\n --gg-mc-opacity: 0.85;\n position: fixed;\n inset: 0;\n z-index: 1000;\n overflow: hidden;\n pointer-events: none;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n -webkit-touch-callout: none;\n -webkit-tap-highlight-color: transparent;\n color: var(--gg-mc-color);\n opacity: var(--gg-mc-opacity);\n font: 600 calc(var(--gg-mc-unit) * 2.4) / 1 system-ui, -apple-system, 'Segoe UI', sans-serif;\n}\n.gg-mc.gg-mc--contained {\n position: absolute;\n}\n.gg-mc-control {\n position: absolute;\n box-sizing: border-box;\n pointer-events: auto;\n touch-action: none;\n}\n.gg-mc-control[hidden] {\n display: none;\n}\n.gg-mc-control svg {\n width: 52%;\n height: 52%;\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n pointer-events: none;\n}\n.gg-mc-button {\n width: calc(var(--gg-mc-unit) * 8);\n height: calc(var(--gg-mc-unit) * 8);\n display: flex;\n align-items: center;\n justify-content: center;\n border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);\n border-radius: 50%;\n background: var(--gg-mc-background);\n transition: transform 60ms ease-out, background-color 60ms ease-out;\n}\n.gg-mc-button.gg-mc-active {\n background: var(--gg-mc-active-background);\n transform: scale(0.93);\n}\n.gg-mc-stick {\n width: calc(var(--gg-mc-unit) * 15);\n height: calc(var(--gg-mc-unit) * 15);\n}\n.gg-mc-stick-base {\n position: absolute;\n left: 50%;\n top: 50%;\n width: calc(var(--gg-mc-unit) * 15);\n height: calc(var(--gg-mc-unit) * 15);\n box-sizing: border-box;\n border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);\n border-radius: 50%;\n background: var(--gg-mc-background);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n.gg-mc-stick--floating .gg-mc-stick-base {\n left: calc(var(--gg-mc-unit) * 13 + env(safe-area-inset-left, 0px));\n top: calc(100% - var(--gg-mc-unit) * 12 - env(safe-area-inset-bottom, 0px));\n opacity: 0.55;\n transition: opacity 120ms ease-out;\n}\n.gg-mc-stick--floating.gg-mc-active .gg-mc-stick-base {\n opacity: 1;\n}\n.gg-mc-stick-knob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 44%;\n height: 44%;\n box-sizing: border-box;\n border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);\n border-radius: 50%;\n background: var(--gg-mc-active-background);\n transform: translate(-50%, -50%);\n}\n.gg-mc-dpad {\n width: calc(var(--gg-mc-unit) * 19);\n height: calc(var(--gg-mc-unit) * 19);\n}\n.gg-mc-dpad-arm {\n position: absolute;\n width: 34%;\n height: 34%;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);\n border-radius: 22%;\n background: var(--gg-mc-background);\n transition: background-color 60ms ease-out;\n pointer-events: none;\n}\n.gg-mc-dpad-arm.gg-mc-active {\n background: var(--gg-mc-active-background);\n}\n.gg-mc-dpad-arm--up {\n left: 33%;\n top: 0;\n}\n.gg-mc-dpad-arm--down {\n left: 33%;\n bottom: 0;\n}\n.gg-mc-dpad-arm--left {\n left: 0;\n top: 33%;\n}\n.gg-mc-dpad-arm--right {\n right: 0;\n top: 33%;\n}\n";
13
+ /**
14
+ * Adds the default stylesheet to the document head, once per document.
15
+ */
16
+ export declare function injectMobileControlsStyles(): void;
package/dist/styles.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The id of the `<style>` element `injectMobileControlsStyles` adds to the document head.
3
+ */
4
+ export const MOBILE_CONTROLS_STYLE_ID = 'gg-mobile-controls-styles';
5
+ /**
6
+ * The default stylesheet of the overlay. `--gg-mc-unit` follows the viewport so that the built-in
7
+ * layouts, which are up to 55 units wide, fit a phone held upright as well as on its side (at least
8
+ * 58 units across). Everything is sized in `--gg-mc-unit` and colored through
9
+ * the other `--gg-mc-*` custom properties declared on `.gg-mc`, so an app restyles the overlay by
10
+ * overriding those on its own class, and a single control through its `.gg-mc-id-*` class.
11
+ */
12
+ export const MOBILE_CONTROLS_CSS = `
13
+ .gg-mc {
14
+ --gg-mc-unit: clamp(5px, min(2vmin, 1.7vw), 10px);
15
+ --gg-mc-color: #fff;
16
+ --gg-mc-background: rgba(18, 22, 30, 0.3);
17
+ --gg-mc-border: rgba(255, 255, 255, 0.6);
18
+ --gg-mc-active-background: rgba(255, 255, 255, 0.4);
19
+ --gg-mc-opacity: 0.85;
20
+ position: fixed;
21
+ inset: 0;
22
+ z-index: 1000;
23
+ overflow: hidden;
24
+ pointer-events: none;
25
+ touch-action: none;
26
+ user-select: none;
27
+ -webkit-user-select: none;
28
+ -webkit-touch-callout: none;
29
+ -webkit-tap-highlight-color: transparent;
30
+ color: var(--gg-mc-color);
31
+ opacity: var(--gg-mc-opacity);
32
+ font: 600 calc(var(--gg-mc-unit) * 2.4) / 1 system-ui, -apple-system, 'Segoe UI', sans-serif;
33
+ }
34
+ .gg-mc.gg-mc--contained {
35
+ position: absolute;
36
+ }
37
+ .gg-mc-control {
38
+ position: absolute;
39
+ box-sizing: border-box;
40
+ pointer-events: auto;
41
+ touch-action: none;
42
+ }
43
+ .gg-mc-control[hidden] {
44
+ display: none;
45
+ }
46
+ .gg-mc-control svg {
47
+ width: 52%;
48
+ height: 52%;
49
+ fill: none;
50
+ stroke: currentColor;
51
+ stroke-width: 2;
52
+ stroke-linecap: round;
53
+ stroke-linejoin: round;
54
+ pointer-events: none;
55
+ }
56
+ .gg-mc-button {
57
+ width: calc(var(--gg-mc-unit) * 8);
58
+ height: calc(var(--gg-mc-unit) * 8);
59
+ display: flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
63
+ border-radius: 50%;
64
+ background: var(--gg-mc-background);
65
+ transition: transform 60ms ease-out, background-color 60ms ease-out;
66
+ }
67
+ .gg-mc-button.gg-mc-active {
68
+ background: var(--gg-mc-active-background);
69
+ transform: scale(0.93);
70
+ }
71
+ .gg-mc-stick {
72
+ width: calc(var(--gg-mc-unit) * 15);
73
+ height: calc(var(--gg-mc-unit) * 15);
74
+ }
75
+ .gg-mc-stick-base {
76
+ position: absolute;
77
+ left: 50%;
78
+ top: 50%;
79
+ width: calc(var(--gg-mc-unit) * 15);
80
+ height: calc(var(--gg-mc-unit) * 15);
81
+ box-sizing: border-box;
82
+ border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
83
+ border-radius: 50%;
84
+ background: var(--gg-mc-background);
85
+ transform: translate(-50%, -50%);
86
+ pointer-events: none;
87
+ }
88
+ .gg-mc-stick--floating .gg-mc-stick-base {
89
+ left: calc(var(--gg-mc-unit) * 13 + env(safe-area-inset-left, 0px));
90
+ top: calc(100% - var(--gg-mc-unit) * 12 - env(safe-area-inset-bottom, 0px));
91
+ opacity: 0.55;
92
+ transition: opacity 120ms ease-out;
93
+ }
94
+ .gg-mc-stick--floating.gg-mc-active .gg-mc-stick-base {
95
+ opacity: 1;
96
+ }
97
+ .gg-mc-stick-knob {
98
+ position: absolute;
99
+ left: 50%;
100
+ top: 50%;
101
+ width: 44%;
102
+ height: 44%;
103
+ box-sizing: border-box;
104
+ border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
105
+ border-radius: 50%;
106
+ background: var(--gg-mc-active-background);
107
+ transform: translate(-50%, -50%);
108
+ }
109
+ .gg-mc-dpad {
110
+ width: calc(var(--gg-mc-unit) * 19);
111
+ height: calc(var(--gg-mc-unit) * 19);
112
+ }
113
+ .gg-mc-dpad-arm {
114
+ position: absolute;
115
+ width: 34%;
116
+ height: 34%;
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ box-sizing: border-box;
121
+ border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
122
+ border-radius: 22%;
123
+ background: var(--gg-mc-background);
124
+ transition: background-color 60ms ease-out;
125
+ pointer-events: none;
126
+ }
127
+ .gg-mc-dpad-arm.gg-mc-active {
128
+ background: var(--gg-mc-active-background);
129
+ }
130
+ .gg-mc-dpad-arm--up {
131
+ left: 33%;
132
+ top: 0;
133
+ }
134
+ .gg-mc-dpad-arm--down {
135
+ left: 33%;
136
+ bottom: 0;
137
+ }
138
+ .gg-mc-dpad-arm--left {
139
+ left: 0;
140
+ top: 33%;
141
+ }
142
+ .gg-mc-dpad-arm--right {
143
+ right: 0;
144
+ top: 33%;
145
+ }
146
+ `;
147
+ /**
148
+ * Adds the default stylesheet to the document head, once per document.
149
+ */
150
+ export function injectMobileControlsStyles() {
151
+ if (typeof document === 'undefined' || document.getElementById(MOBILE_CONTROLS_STYLE_ID)) {
152
+ return;
153
+ }
154
+ const style = document.createElement('style');
155
+ style.id = MOBILE_CONTROLS_STYLE_ID;
156
+ style.textContent = MOBILE_CONTROLS_CSS;
157
+ document.head.appendChild(style);
158
+ }
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@gg-web-engine/mobile-controls",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.78",
4
+ "description": "On-screen touch controls for gg-web-engine: buttons, sticks, d-pads, look areas and tilt steering over the game canvas",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "test": "jest",
9
+ "prettier-format": "prettier --config ../../.prettierrc 'src/**/*.ts' --write",
10
+ "prepublish": "rm -rf ./dist/ && tsc",
11
+ "build": "tsc"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/AndyGura/gg-web-engine.git"
16
+ },
17
+ "keywords": [
18
+ "game-development",
19
+ "game-engine",
20
+ "game-engine-framework",
21
+ "gamedev",
22
+ "touch-controls",
23
+ "virtual-joystick",
24
+ "mobile"
25
+ ],
26
+ "author": "AndyGura",
27
+ "license": "Apache-2.0",
28
+ "bugs": {
29
+ "url": "https://github.com/AndyGura/gg-web-engine/issues"
30
+ },
31
+ "homepage": "https://github.com/AndyGura/gg-web-engine#readme",
32
+ "dependencies": {},
33
+ "devDependencies": {
34
+ "@gg-web-engine/core": "0.0.78",
35
+ "@types/jest": "^30.0.0",
36
+ "@types/node": "^26.4.0",
37
+ "jest": "^30.5.0",
38
+ "jest-environment-jsdom": "^30.5.0",
39
+ "prettier": "^3.9.6",
40
+ "rxjs": "7.8.2",
41
+ "ts-jest": "^29.4.12",
42
+ "typescript": "~6.0.3"
43
+ },
44
+ "peerDependencies": {
45
+ "@gg-web-engine/core": "0.0.78",
46
+ "rxjs": "7.8.2"
47
+ },
48
+ "jest": {
49
+ "rootDir": ".",
50
+ "testRegex": "\\.spec\\.ts$",
51
+ "transform": {
52
+ "^.+\\.(t|j)s$": "ts-jest"
53
+ },
54
+ "moduleNameMapper": {
55
+ "@gg-web-engine/core": "<rootDir>/../core/src/index.ts"
56
+ },
57
+ "preset": "ts-jest/presets/default",
58
+ "collectCoverageFrom": [
59
+ "**/*.ts",
60
+ "!**/*.module.ts"
61
+ ],
62
+ "coveragePathIgnorePatterns": [
63
+ "<rootDir>/node_modules/",
64
+ "<rootDir>/dist/",
65
+ "<rootDir>/test/"
66
+ ],
67
+ "coverageDirectory": "./coverage",
68
+ "testEnvironment": "jsdom"
69
+ }
70
+ }
@@ -0,0 +1,105 @@
1
+ import { BehaviorSubject, Observable, takeUntil } from 'rxjs';
2
+ import { distinctUntilChanged } from 'rxjs/operators';
3
+ import { DirectionInput, KeyboardInput, MouseInput, Pnt2, Point2 } from '@gg-web-engine/core';
4
+ import { TouchControl, TouchControlOptions } from './touch-control';
5
+
6
+ /** The key codes a `TouchAxisControl` presses for each direction - see `bindKeys`. */
7
+ export type AxisKeys = { up?: string; down?: string; left?: string; right?: string };
8
+
9
+ /**
10
+ * The base of the controls that point in a direction (`TouchStick`, `TouchDPad`). `value$` is a
11
+ * vector with `x` from -1 (left) to 1 (right) and `y` from -1 (down) to 1 (up), never longer than 1 -
12
+ * the same convention as `DirectionInput.direction$`.
13
+ */
14
+ export abstract class TouchAxisControl extends TouchControl {
15
+ private readonly _value$: BehaviorSubject<Point2> = new BehaviorSubject<Point2>(Pnt2.O);
16
+
17
+ /** Emits the current value on subscription and then every change; completes on `dispose`. */
18
+ public get value$(): Observable<Point2> {
19
+ return this._value$.pipe(
20
+ distinctUntilChanged((a, b) => a.x === b.x && a.y === b.y),
21
+ takeUntil(this.disposed$),
22
+ );
23
+ }
24
+
25
+ public get value(): Point2 {
26
+ return this._value$.getValue();
27
+ }
28
+
29
+ protected constructor(kind: string, options: TouchControlOptions) {
30
+ super(kind, options);
31
+ }
32
+
33
+ protected setValue(value: Point2): void {
34
+ if (!this.disposed) {
35
+ this._value$.next(value);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Feeds the control into `input` as an analog direction, which every built-in controller moving by
41
+ * direction keys (car, character, free camera) follows proportionally.
42
+ */
43
+ public bindDirection(input: DirectionInput): this {
44
+ this.value$.subscribe(v => input.setAnalogDirection(this, v.x === 0 && v.y === 0 ? null : v));
45
+ return this;
46
+ }
47
+
48
+ /**
49
+ * Makes the control hold a key of `keyboard` down per direction while it points that way further
50
+ * than `threshold` - for anything that only understands keys.
51
+ */
52
+ public bindKeys(keyboard: KeyboardInput, keys: AxisKeys, threshold: number = 0.5): this {
53
+ const held: { [code: string]: boolean } = {};
54
+ const set = (code: string | undefined, down: boolean) => {
55
+ if (!code || !!held[code] === down) {
56
+ return;
57
+ }
58
+ held[code] = down;
59
+ down ? keyboard.emulateKeyDown(code) : keyboard.emulateKeyUp(code);
60
+ };
61
+ this.value$.subscribe(v => {
62
+ set(keys.up, v.y > threshold);
63
+ set(keys.down, v.y < -threshold);
64
+ set(keys.left, v.x < -threshold);
65
+ set(keys.right, v.x > threshold);
66
+ });
67
+ return this;
68
+ }
69
+
70
+ /**
71
+ * Makes the control turn a view the way a mouse does: while it is deflected, `mouse` reports a
72
+ * continuous movement of up to `speed` pixels per second in that direction.
73
+ */
74
+ public bindLook(mouse: MouseInput, speed: number = 900): this {
75
+ let frame: number | null = null;
76
+ let last = 0;
77
+ const step = (time: number) => {
78
+ frame = null;
79
+ const { x, y } = this.value;
80
+ if (this.disposed || (x === 0 && y === 0)) {
81
+ return;
82
+ }
83
+ // the first frame's timestamp is its start time, which may precede the moment `last` was taken
84
+ const dt = Math.max(0, Math.min(100, time - last)) / 1000;
85
+ last = time;
86
+ // the pointer's y grows downwards, the control's upwards
87
+ mouse.emulateMove({ x: x * speed * dt, y: -y * speed * dt });
88
+ frame = requestAnimationFrame(step);
89
+ };
90
+ this.value$.subscribe({
91
+ next: v => {
92
+ if (frame === null && (v.x !== 0 || v.y !== 0)) {
93
+ last = performance.now();
94
+ frame = requestAnimationFrame(step);
95
+ }
96
+ },
97
+ complete: () => {
98
+ if (frame !== null) {
99
+ cancelAnimationFrame(frame);
100
+ }
101
+ },
102
+ });
103
+ return this;
104
+ }
105
+ }