@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.
- package/README.md +151 -2
- package/dist/controls/touch-axis-control.d.ts +38 -0
- package/dist/controls/touch-axis-control.js +91 -0
- package/dist/controls/touch-button.d.ts +49 -0
- package/dist/controls/touch-button.js +111 -0
- package/dist/controls/touch-control.d.ts +76 -0
- package/dist/controls/touch-control.js +151 -0
- package/dist/controls/touch-dpad.d.ts +27 -0
- package/dist/controls/touch-dpad.js +61 -0
- package/dist/controls/touch-look-area.d.ts +35 -0
- package/dist/controls/touch-look-area.js +61 -0
- package/dist/controls/touch-stick.d.ts +37 -0
- package/dist/controls/touch-stick.js +71 -0
- package/dist/icons.d.ts +19 -0
- package/dist/icons.js +20 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/inputs/tilt.input.d.ts +40 -0
- package/dist/inputs/tilt.input.js +106 -0
- package/dist/layouts/car.layout.d.ts +31 -0
- package/dist/layouts/car.layout.js +99 -0
- package/dist/layouts/character-2d.layout.d.ts +15 -0
- package/dist/layouts/character-2d.layout.js +60 -0
- package/dist/layouts/character.layout.d.ts +25 -0
- package/dist/layouts/character.layout.js +87 -0
- package/dist/layouts/free-camera.layout.d.ts +16 -0
- package/dist/layouts/free-camera.layout.js +73 -0
- package/dist/mobile-controls-layout.d.ts +54 -0
- package/dist/mobile-controls-layout.js +47 -0
- package/dist/mobile-controls.entity.d.ts +112 -0
- package/dist/mobile-controls.entity.js +274 -0
- package/dist/styles.d.ts +16 -0
- package/dist/styles.js +158 -0
- package/package.json +68 -4
- package/src/controls/touch-axis-control.ts +105 -0
- package/src/controls/touch-button.ts +136 -0
- package/src/controls/touch-control.ts +204 -0
- package/src/controls/touch-dpad.ts +78 -0
- package/src/controls/touch-look-area.ts +78 -0
- package/src/controls/touch-stick.ts +100 -0
- package/src/icons.ts +23 -0
- package/src/index.ts +15 -0
- package/src/inputs/tilt.input.ts +128 -0
- package/src/layouts/car.layout.ts +139 -0
- package/src/layouts/character-2d.layout.ts +85 -0
- package/src/layouts/character.layout.ts +125 -0
- package/src/layouts/free-camera.layout.ts +103 -0
- package/src/mobile-controls-layout.ts +86 -0
- package/src/mobile-controls.entity.ts +350 -0
- package/src/styles.ts +160 -0
- package/test/helpers.ts +38 -0
- package/test/mobile-controls.spec.ts +204 -0
- package/test/tilt.input.spec.ts +95 -0
- package/test/touch-axis-controls.spec.ts +172 -0
- package/test/touch-button.spec.ts +162 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { takeUntil } from 'rxjs';
|
|
2
|
+
import {
|
|
3
|
+
CarHandlingController,
|
|
4
|
+
FreeCameraController,
|
|
5
|
+
GgCarHandlingController,
|
|
6
|
+
GgWorld,
|
|
7
|
+
IEntity,
|
|
8
|
+
PlayerCharacterController,
|
|
9
|
+
PlayerCharacterController2d,
|
|
10
|
+
TickOrder,
|
|
11
|
+
} from '@gg-web-engine/core';
|
|
12
|
+
import { TouchControl } from './controls/touch-control';
|
|
13
|
+
import { carLayout, CarLayoutOptions } from './layouts/car.layout';
|
|
14
|
+
import { character2dLayout, Character2dLayoutOptions } from './layouts/character-2d.layout';
|
|
15
|
+
import { characterLayout, CharacterLayoutOptions } from './layouts/character.layout';
|
|
16
|
+
import { freeCameraLayout, FreeCameraLayoutOptions } from './layouts/free-camera.layout';
|
|
17
|
+
import { MobileControlsLayoutFactory, MobileControlsLayoutItem } from './mobile-controls-layout';
|
|
18
|
+
import { injectMobileControlsStyles } from './styles';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether the device is operated by touch first (a phone, a tablet) - what `enabled: 'auto'` goes by.
|
|
22
|
+
* A laptop that merely has a touch screen next to its mouse does not count.
|
|
23
|
+
*/
|
|
24
|
+
export function isTouchFirstDevice(): boolean {
|
|
25
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (typeof window.matchMedia === 'function') {
|
|
29
|
+
return window.matchMedia('(pointer: coarse)').matches;
|
|
30
|
+
}
|
|
31
|
+
return navigator.maxTouchPoints > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MobileControlsOptions = {
|
|
35
|
+
/**
|
|
36
|
+
* `'auto'` (default): the overlay exists only on a touch-first device (`isTouchFirstDevice`).
|
|
37
|
+
* `true`: always, e.g. to try the controls with a mouse. `false`: never.
|
|
38
|
+
*/
|
|
39
|
+
enabled: boolean | 'auto';
|
|
40
|
+
/**
|
|
41
|
+
* The element the overlay is added to. `null` (default) puts it on `document.body`, covering the
|
|
42
|
+
* viewport - right for a game filling the page. For a canvas that takes a part of the page, or one
|
|
43
|
+
* that enters fullscreen through the Fullscreen API, pass an element wrapping the canvas (it has to
|
|
44
|
+
* be positioned, e.g. `position: relative`): the overlay then covers exactly that element.
|
|
45
|
+
*/
|
|
46
|
+
container: HTMLElement | null;
|
|
47
|
+
/** Extra CSS class names for the overlay element, e.g. one overriding its `--gg-mc-*` properties. */
|
|
48
|
+
className: string;
|
|
49
|
+
/** Whether to add the default stylesheet to the page. `true` by default. */
|
|
50
|
+
injectStyles: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Options of the built-in layout for `GgCarHandlingController` and a bare
|
|
53
|
+
* `CarHandlingController`, or `false` to have none.
|
|
54
|
+
*/
|
|
55
|
+
car: CarLayoutOptions | false;
|
|
56
|
+
/** Options of the built-in layout for `PlayerCharacterController`, or `false` to have none. */
|
|
57
|
+
character: CharacterLayoutOptions | false;
|
|
58
|
+
/** Options of the built-in layout for `PlayerCharacterController2d`, or `false` to have none. */
|
|
59
|
+
character2d: Character2dLayoutOptions | false;
|
|
60
|
+
/** Options of the built-in layout for `FreeCameraController`, or `false` to have none. */
|
|
61
|
+
freeCamera: FreeCameraLayoutOptions | false;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const DEFAULT_OPTIONS: MobileControlsOptions = {
|
|
65
|
+
enabled: 'auto',
|
|
66
|
+
container: null,
|
|
67
|
+
className: '',
|
|
68
|
+
injectStyles: true,
|
|
69
|
+
car: {},
|
|
70
|
+
character: {},
|
|
71
|
+
character2d: {},
|
|
72
|
+
freeCamera: {},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type ControllerClass<T extends IEntity> = abstract new (...args: any[]) => T;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* An overlay of on-screen touch controls over the game canvas. Add it to a world once, and it shows
|
|
79
|
+
* the controls matching whichever input controller is active in that world at the moment, swapping
|
|
80
|
+
* them as controllers are activated, deactivated, spawned and removed:
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* world.addEntity(new MobileControls());
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* Out of the box it knows the car controllers (`GgCarHandlingController`,
|
|
87
|
+
* `CarHandlingController`), `PlayerCharacterController`, `PlayerCharacterController2d` and
|
|
88
|
+
* `FreeCameraController`. Each of those layouts is adjusted through
|
|
89
|
+
* its options (see `MobileControlsOptions`), replaced by `registerLayout`, and any other controller
|
|
90
|
+
* class - including an app's own - gets a layout the same way. Controls that belong to no controller
|
|
91
|
+
* (pause, a menu button) are added with `addControls`.
|
|
92
|
+
*/
|
|
93
|
+
export class MobileControls extends IEntity {
|
|
94
|
+
static readonly entityTypeName: string = 'MobileControls';
|
|
95
|
+
public readonly tickOrder = TickOrder.INPUT_CONTROLLERS;
|
|
96
|
+
|
|
97
|
+
public readonly options: MobileControlsOptions;
|
|
98
|
+
|
|
99
|
+
/** The overlay element. Created up front, attached to the page only while the overlay is shown. */
|
|
100
|
+
public readonly element: HTMLElement;
|
|
101
|
+
|
|
102
|
+
private readonly registrations: {
|
|
103
|
+
controllerClass: ControllerClass<any>;
|
|
104
|
+
factory: MobileControlsLayoutFactory<any>;
|
|
105
|
+
}[] = [];
|
|
106
|
+
private readonly tracked: Set<IEntity> = new Set<IEntity>();
|
|
107
|
+
private readonly layouts: Map<IEntity, MobileControlsLayoutItem[]> = new Map<IEntity, MobileControlsLayoutItem[]>();
|
|
108
|
+
private readonly ownControls: Set<TouchControl> = new Set<TouchControl>();
|
|
109
|
+
private _enabled: boolean | 'auto';
|
|
110
|
+
private _visible: boolean = true;
|
|
111
|
+
|
|
112
|
+
public get enabled(): boolean | 'auto' {
|
|
113
|
+
return this._enabled;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** See `MobileControlsOptions.enabled`. */
|
|
117
|
+
public set enabled(value: boolean | 'auto') {
|
|
118
|
+
this._enabled = value;
|
|
119
|
+
this.sync();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public get visible(): boolean {
|
|
123
|
+
return this._visible;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Hides the overlay without disabling it, for the moments touch controls are in the way (a menu,
|
|
128
|
+
* a cutscene). Everything held is released.
|
|
129
|
+
*/
|
|
130
|
+
public set visible(value: boolean) {
|
|
131
|
+
this._visible = value;
|
|
132
|
+
this.sync();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether the overlay is on the page right now. */
|
|
136
|
+
public get shown(): boolean {
|
|
137
|
+
return this.element.isConnected;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The controllers that currently have their controls on screen. */
|
|
141
|
+
public get activeControllers(): IEntity[] {
|
|
142
|
+
return [...this.layouts.keys()];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
constructor(options: Partial<MobileControlsOptions> = {}) {
|
|
146
|
+
super();
|
|
147
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
148
|
+
this._enabled = this.options.enabled;
|
|
149
|
+
this.element = document.createElement('div');
|
|
150
|
+
this.element.className = 'gg-mc';
|
|
151
|
+
if (this.options.container) {
|
|
152
|
+
this.element.classList.add('gg-mc--contained');
|
|
153
|
+
}
|
|
154
|
+
if (this.options.className) {
|
|
155
|
+
this.element.classList.add(...this.options.className.split(/\s+/).filter(x => !!x));
|
|
156
|
+
}
|
|
157
|
+
this.releaseAll = this.releaseAll.bind(this);
|
|
158
|
+
if (this.options.car) {
|
|
159
|
+
const layout = carLayout(this.options.car);
|
|
160
|
+
this.registerLayout(CarHandlingController, layout);
|
|
161
|
+
this.registerLayout(GgCarHandlingController, layout);
|
|
162
|
+
}
|
|
163
|
+
if (this.options.character) {
|
|
164
|
+
this.registerLayout(PlayerCharacterController, characterLayout(this.options.character));
|
|
165
|
+
}
|
|
166
|
+
if (this.options.character2d) {
|
|
167
|
+
this.registerLayout(PlayerCharacterController2d, character2dLayout(this.options.character2d));
|
|
168
|
+
}
|
|
169
|
+
if (this.options.freeCamera) {
|
|
170
|
+
this.registerLayout(FreeCameraController, freeCameraLayout(this.options.freeCamera));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Sets the layout shown while a controller of `controllerClass` (or of a subclass) is active,
|
|
176
|
+
* replacing a layout registered for the same class before; `null` leaves that class without one.
|
|
177
|
+
* Of several registered classes a controller is an instance of, the one registered last wins.
|
|
178
|
+
*/
|
|
179
|
+
public registerLayout<T extends IEntity>(
|
|
180
|
+
controllerClass: ControllerClass<T>,
|
|
181
|
+
factory: MobileControlsLayoutFactory<T> | null,
|
|
182
|
+
): void {
|
|
183
|
+
const index = this.registrations.findIndex(r => r.controllerClass === controllerClass);
|
|
184
|
+
if (index >= 0) {
|
|
185
|
+
this.registrations.splice(index, 1);
|
|
186
|
+
}
|
|
187
|
+
if (factory) {
|
|
188
|
+
this.registrations.push({ controllerClass, factory });
|
|
189
|
+
}
|
|
190
|
+
if (this.world) {
|
|
191
|
+
this.rescan();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Adds controls that stay on screen whichever controller is active, on top of the layouts.
|
|
197
|
+
*/
|
|
198
|
+
public addControls(...controls: TouchControl[]): void {
|
|
199
|
+
for (const control of controls) {
|
|
200
|
+
this.ownControls.add(control);
|
|
201
|
+
this.element.appendChild(control.element);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Removes controls added by `addControls` and disposes them. */
|
|
206
|
+
public removeControls(...controls: TouchControl[]): void {
|
|
207
|
+
for (const control of controls) {
|
|
208
|
+
if (this.ownControls.delete(control)) {
|
|
209
|
+
control.dispose();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Builds the layout of `controller` anew - after something its layout was built from has changed
|
|
216
|
+
* (the car it drives, an option), since a layout is otherwise built once per activation.
|
|
217
|
+
*/
|
|
218
|
+
public refresh(controller?: IEntity): void {
|
|
219
|
+
for (const key of controller ? [controller] : [...this.layouts.keys()]) {
|
|
220
|
+
this.disposeLayout(key);
|
|
221
|
+
}
|
|
222
|
+
this.sync();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
onSpawned(world: GgWorld<any, any>): void {
|
|
226
|
+
super.onSpawned(world);
|
|
227
|
+
world.entityAdded$.pipe(takeUntil(this._onRemoved$)).subscribe(entity => {
|
|
228
|
+
if (this.findFactory(entity)) {
|
|
229
|
+
this.tracked.add(entity);
|
|
230
|
+
this.sync();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
world.entityRemoved$.pipe(takeUntil(this._onRemoved$)).subscribe(entity => {
|
|
234
|
+
if (this.tracked.delete(entity)) {
|
|
235
|
+
this.sync();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
// a world whose input is switched off (a screen covering the game) takes its controls off the
|
|
239
|
+
// screen; it may be paused at that moment, so this can't wait for the next tick
|
|
240
|
+
world.inputEnabled$.pipe(takeUntil(this._onRemoved$)).subscribe(() => this.sync());
|
|
241
|
+
// `active` has no change notification, so it is polled
|
|
242
|
+
this.tick$.pipe(takeUntil(this._onRemoved$)).subscribe(() => this.sync());
|
|
243
|
+
window.addEventListener('blur', this.releaseAll);
|
|
244
|
+
this.rescan();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
onRemoved(): void {
|
|
248
|
+
super.onRemoved();
|
|
249
|
+
window.removeEventListener('blur', this.releaseAll);
|
|
250
|
+
this.tracked.clear();
|
|
251
|
+
this.sync();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
dispose(): void {
|
|
255
|
+
super.dispose();
|
|
256
|
+
for (const control of this.ownControls) {
|
|
257
|
+
control.dispose();
|
|
258
|
+
}
|
|
259
|
+
this.ownControls.clear();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Returns every control on screen to its untouched state. */
|
|
263
|
+
public releaseAll(): void {
|
|
264
|
+
for (const items of this.layouts.values()) {
|
|
265
|
+
for (const item of items) {
|
|
266
|
+
if (item instanceof TouchControl) {
|
|
267
|
+
item.reset();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const control of this.ownControls) {
|
|
272
|
+
control.reset();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private findFactory(entity: IEntity): MobileControlsLayoutFactory<any> | null {
|
|
277
|
+
for (let i = this.registrations.length - 1; i >= 0; i--) {
|
|
278
|
+
if (entity instanceof this.registrations[i].controllerClass) {
|
|
279
|
+
return this.registrations[i].factory;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private rescan(): void {
|
|
286
|
+
this.tracked.clear();
|
|
287
|
+
const visit = (entity: IEntity) => {
|
|
288
|
+
if (this.findFactory(entity)) {
|
|
289
|
+
this.tracked.add(entity);
|
|
290
|
+
}
|
|
291
|
+
entity.children.forEach(visit);
|
|
292
|
+
};
|
|
293
|
+
(this.world?.children || []).forEach(visit);
|
|
294
|
+
for (const controller of [...this.layouts.keys()]) {
|
|
295
|
+
this.disposeLayout(controller);
|
|
296
|
+
}
|
|
297
|
+
this.sync();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private sync(): void {
|
|
301
|
+
const world = this.world;
|
|
302
|
+
const show =
|
|
303
|
+
!!world &&
|
|
304
|
+
this._visible &&
|
|
305
|
+
world.inputEnabled !== false &&
|
|
306
|
+
(this._enabled === 'auto' ? isTouchFirstDevice() : this._enabled);
|
|
307
|
+
if (show !== this.shown) {
|
|
308
|
+
if (show) {
|
|
309
|
+
if (this.options.injectStyles) {
|
|
310
|
+
injectMobileControlsStyles();
|
|
311
|
+
}
|
|
312
|
+
(this.options.container || document.body).appendChild(this.element);
|
|
313
|
+
} else {
|
|
314
|
+
this.releaseAll();
|
|
315
|
+
this.element.remove();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const controller of [...this.layouts.keys()]) {
|
|
319
|
+
if (!show || !this.tracked.has(controller) || !controller.active) {
|
|
320
|
+
this.disposeLayout(controller);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!show) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const controller of this.tracked) {
|
|
327
|
+
if (controller.active && !this.layouts.has(controller)) {
|
|
328
|
+
const items = this.findFactory(controller)!(controller, { world: world!, controls: this });
|
|
329
|
+
this.layouts.set(controller, items);
|
|
330
|
+
// below the controls added by `addControls`, which stay on top
|
|
331
|
+
const before = this.ownControls.size > 0 ? [...this.ownControls][0].element : null;
|
|
332
|
+
for (const item of items) {
|
|
333
|
+
if (item instanceof TouchControl) {
|
|
334
|
+
this.element.insertBefore(item.element, before);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private disposeLayout(controller: IEntity): void {
|
|
342
|
+
const items = this.layouts.get(controller);
|
|
343
|
+
if (items) {
|
|
344
|
+
this.layouts.delete(controller);
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
item.dispose();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* The default stylesheet of the overlay. `--gg-mc-unit` follows the viewport so that the built-in
|
|
8
|
+
* layouts, which are up to 55 units wide, fit a phone held upright as well as on its side (at least
|
|
9
|
+
* 58 units across). Everything is sized in `--gg-mc-unit` and colored through
|
|
10
|
+
* the other `--gg-mc-*` custom properties declared on `.gg-mc`, so an app restyles the overlay by
|
|
11
|
+
* overriding those on its own class, and a single control through its `.gg-mc-id-*` class.
|
|
12
|
+
*/
|
|
13
|
+
export const MOBILE_CONTROLS_CSS = `
|
|
14
|
+
.gg-mc {
|
|
15
|
+
--gg-mc-unit: clamp(5px, min(2vmin, 1.7vw), 10px);
|
|
16
|
+
--gg-mc-color: #fff;
|
|
17
|
+
--gg-mc-background: rgba(18, 22, 30, 0.3);
|
|
18
|
+
--gg-mc-border: rgba(255, 255, 255, 0.6);
|
|
19
|
+
--gg-mc-active-background: rgba(255, 255, 255, 0.4);
|
|
20
|
+
--gg-mc-opacity: 0.85;
|
|
21
|
+
position: fixed;
|
|
22
|
+
inset: 0;
|
|
23
|
+
z-index: 1000;
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
touch-action: none;
|
|
27
|
+
user-select: none;
|
|
28
|
+
-webkit-user-select: none;
|
|
29
|
+
-webkit-touch-callout: none;
|
|
30
|
+
-webkit-tap-highlight-color: transparent;
|
|
31
|
+
color: var(--gg-mc-color);
|
|
32
|
+
opacity: var(--gg-mc-opacity);
|
|
33
|
+
font: 600 calc(var(--gg-mc-unit) * 2.4) / 1 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
34
|
+
}
|
|
35
|
+
.gg-mc.gg-mc--contained {
|
|
36
|
+
position: absolute;
|
|
37
|
+
}
|
|
38
|
+
.gg-mc-control {
|
|
39
|
+
position: absolute;
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
pointer-events: auto;
|
|
42
|
+
touch-action: none;
|
|
43
|
+
}
|
|
44
|
+
.gg-mc-control[hidden] {
|
|
45
|
+
display: none;
|
|
46
|
+
}
|
|
47
|
+
.gg-mc-control svg {
|
|
48
|
+
width: 52%;
|
|
49
|
+
height: 52%;
|
|
50
|
+
fill: none;
|
|
51
|
+
stroke: currentColor;
|
|
52
|
+
stroke-width: 2;
|
|
53
|
+
stroke-linecap: round;
|
|
54
|
+
stroke-linejoin: round;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
.gg-mc-button {
|
|
58
|
+
width: calc(var(--gg-mc-unit) * 8);
|
|
59
|
+
height: calc(var(--gg-mc-unit) * 8);
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: center;
|
|
63
|
+
border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
|
|
64
|
+
border-radius: 50%;
|
|
65
|
+
background: var(--gg-mc-background);
|
|
66
|
+
transition: transform 60ms ease-out, background-color 60ms ease-out;
|
|
67
|
+
}
|
|
68
|
+
.gg-mc-button.gg-mc-active {
|
|
69
|
+
background: var(--gg-mc-active-background);
|
|
70
|
+
transform: scale(0.93);
|
|
71
|
+
}
|
|
72
|
+
.gg-mc-stick {
|
|
73
|
+
width: calc(var(--gg-mc-unit) * 15);
|
|
74
|
+
height: calc(var(--gg-mc-unit) * 15);
|
|
75
|
+
}
|
|
76
|
+
.gg-mc-stick-base {
|
|
77
|
+
position: absolute;
|
|
78
|
+
left: 50%;
|
|
79
|
+
top: 50%;
|
|
80
|
+
width: calc(var(--gg-mc-unit) * 15);
|
|
81
|
+
height: calc(var(--gg-mc-unit) * 15);
|
|
82
|
+
box-sizing: border-box;
|
|
83
|
+
border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
|
|
84
|
+
border-radius: 50%;
|
|
85
|
+
background: var(--gg-mc-background);
|
|
86
|
+
transform: translate(-50%, -50%);
|
|
87
|
+
pointer-events: none;
|
|
88
|
+
}
|
|
89
|
+
.gg-mc-stick--floating .gg-mc-stick-base {
|
|
90
|
+
left: calc(var(--gg-mc-unit) * 13 + env(safe-area-inset-left, 0px));
|
|
91
|
+
top: calc(100% - var(--gg-mc-unit) * 12 - env(safe-area-inset-bottom, 0px));
|
|
92
|
+
opacity: 0.55;
|
|
93
|
+
transition: opacity 120ms ease-out;
|
|
94
|
+
}
|
|
95
|
+
.gg-mc-stick--floating.gg-mc-active .gg-mc-stick-base {
|
|
96
|
+
opacity: 1;
|
|
97
|
+
}
|
|
98
|
+
.gg-mc-stick-knob {
|
|
99
|
+
position: absolute;
|
|
100
|
+
left: 50%;
|
|
101
|
+
top: 50%;
|
|
102
|
+
width: 44%;
|
|
103
|
+
height: 44%;
|
|
104
|
+
box-sizing: border-box;
|
|
105
|
+
border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
|
|
106
|
+
border-radius: 50%;
|
|
107
|
+
background: var(--gg-mc-active-background);
|
|
108
|
+
transform: translate(-50%, -50%);
|
|
109
|
+
}
|
|
110
|
+
.gg-mc-dpad {
|
|
111
|
+
width: calc(var(--gg-mc-unit) * 19);
|
|
112
|
+
height: calc(var(--gg-mc-unit) * 19);
|
|
113
|
+
}
|
|
114
|
+
.gg-mc-dpad-arm {
|
|
115
|
+
position: absolute;
|
|
116
|
+
width: 34%;
|
|
117
|
+
height: 34%;
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
box-sizing: border-box;
|
|
122
|
+
border: calc(var(--gg-mc-unit) * 0.22) solid var(--gg-mc-border);
|
|
123
|
+
border-radius: 22%;
|
|
124
|
+
background: var(--gg-mc-background);
|
|
125
|
+
transition: background-color 60ms ease-out;
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
}
|
|
128
|
+
.gg-mc-dpad-arm.gg-mc-active {
|
|
129
|
+
background: var(--gg-mc-active-background);
|
|
130
|
+
}
|
|
131
|
+
.gg-mc-dpad-arm--up {
|
|
132
|
+
left: 33%;
|
|
133
|
+
top: 0;
|
|
134
|
+
}
|
|
135
|
+
.gg-mc-dpad-arm--down {
|
|
136
|
+
left: 33%;
|
|
137
|
+
bottom: 0;
|
|
138
|
+
}
|
|
139
|
+
.gg-mc-dpad-arm--left {
|
|
140
|
+
left: 0;
|
|
141
|
+
top: 33%;
|
|
142
|
+
}
|
|
143
|
+
.gg-mc-dpad-arm--right {
|
|
144
|
+
right: 0;
|
|
145
|
+
top: 33%;
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Adds the default stylesheet to the document head, once per document.
|
|
151
|
+
*/
|
|
152
|
+
export function injectMobileControlsStyles(): void {
|
|
153
|
+
if (typeof document === 'undefined' || document.getElementById(MOBILE_CONTROLS_STYLE_ID)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const style = document.createElement('style');
|
|
157
|
+
style.id = MOBILE_CONTROLS_STYLE_ID;
|
|
158
|
+
style.textContent = MOBILE_CONTROLS_CSS;
|
|
159
|
+
document.head.appendChild(style);
|
|
160
|
+
}
|
package/test/helpers.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { GgWorld } from '@gg-web-engine/core';
|
|
2
|
+
|
|
3
|
+
export class TestWorld extends GgWorld<any, any> {
|
|
4
|
+
constructor() {
|
|
5
|
+
super({
|
|
6
|
+
visualScene: { init: async () => {}, dispose: () => {} } as any,
|
|
7
|
+
physicsWorld: { init: async () => {}, simulate: () => {}, dispose: () => {} } as any,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
addPrimitiveRigidBody(): any {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const tick = (world: GgWorld<any, any>, elapsed: number = 0, delta: number = 16) =>
|
|
17
|
+
(world.worldClock as any)._tick$.next([elapsed, delta]);
|
|
18
|
+
|
|
19
|
+
/** Dispatches a touch pointer event; built on MouseEvent so it does not depend on jsdom's PointerEvent. */
|
|
20
|
+
export const pointer = (
|
|
21
|
+
target: Element,
|
|
22
|
+
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
|
|
23
|
+
x: number = 0,
|
|
24
|
+
y: number = 0,
|
|
25
|
+
pointerId: number = 1,
|
|
26
|
+
) => {
|
|
27
|
+
const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y });
|
|
28
|
+
Object.defineProperty(event, 'pointerId', { value: pointerId });
|
|
29
|
+
Object.defineProperty(event, 'pointerType', { value: 'touch' });
|
|
30
|
+
target.dispatchEvent(event);
|
|
31
|
+
return event;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Gives an element a layout box, which jsdom never computes. */
|
|
35
|
+
export const setRect = (element: Element, left: number, top: number, width: number, height: number) => {
|
|
36
|
+
element.getBoundingClientRect = () =>
|
|
37
|
+
({ left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) }) as DOMRect;
|
|
38
|
+
};
|