@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,136 @@
|
|
|
1
|
+
import { BehaviorSubject, Observable, skip, takeUntil } from 'rxjs';
|
|
2
|
+
import { distinctUntilChanged, filter } from 'rxjs/operators';
|
|
3
|
+
import { AnalogDirection, DirectionInput, KeyboardInput } from '@gg-web-engine/core';
|
|
4
|
+
import { TouchControl, TouchControlOptions } from './touch-control';
|
|
5
|
+
|
|
6
|
+
export type TouchButtonOptions = TouchControlOptions & {
|
|
7
|
+
/** What the button shows: markup (an inline SVG, plain text) or a DOM node. */
|
|
8
|
+
content?: string | Node;
|
|
9
|
+
/**
|
|
10
|
+
* `'hold'` (default): pressed while a finger is on it. `'toggle'`: every tap flips it, for an
|
|
11
|
+
* action that would otherwise need a finger parked on the button (sprint, crouch).
|
|
12
|
+
*/
|
|
13
|
+
mode?: 'hold' | 'toggle';
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An on-screen button. Read it through `pressed$`, or bind it to what a key already does with
|
|
18
|
+
* `bindKey`, so anything an app maps to its keyboard gets a touch button without further code.
|
|
19
|
+
*/
|
|
20
|
+
export class TouchButton extends TouchControl {
|
|
21
|
+
public readonly mode: 'hold' | 'toggle';
|
|
22
|
+
|
|
23
|
+
private readonly _pressed$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
|
24
|
+
private _pressed: boolean = false;
|
|
25
|
+
private emitting: boolean = false;
|
|
26
|
+
private disposeRequested: boolean = false;
|
|
27
|
+
|
|
28
|
+
/** Emits the current state on subscription and then every change; completes on `dispose`. */
|
|
29
|
+
public get pressed$(): Observable<boolean> {
|
|
30
|
+
return this._pressed$.pipe(distinctUntilChanged(), takeUntil(this.disposed$));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public get pressed(): boolean {
|
|
34
|
+
return this._pressed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Settable, e.g. to bring a toggle button in line with a state that changed by other means. */
|
|
38
|
+
public set pressed(value: boolean) {
|
|
39
|
+
if (this.disposed || value === this._pressed) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this._pressed = value;
|
|
43
|
+
this.element.classList.toggle('gg-mc-active', value);
|
|
44
|
+
this.element.setAttribute('aria-pressed', `${value}`);
|
|
45
|
+
if (this.emitting) {
|
|
46
|
+
// set from within a subscriber: the loop below delivers it once every subscriber has seen
|
|
47
|
+
// the change being emitted, so all of them get the changes in the same order
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.emitting = true;
|
|
51
|
+
try {
|
|
52
|
+
while (this._pressed$.getValue() !== this._pressed) {
|
|
53
|
+
this._pressed$.next(this._pressed);
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
this.emitting = false;
|
|
57
|
+
}
|
|
58
|
+
if (this.disposeRequested) {
|
|
59
|
+
super.dispose();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
constructor(options: TouchButtonOptions = {}) {
|
|
64
|
+
super('button', options);
|
|
65
|
+
this.mode = options.mode || 'hold';
|
|
66
|
+
this.element.setAttribute('role', 'button');
|
|
67
|
+
this.setContent(options.content ?? '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public setContent(content: string | Node): void {
|
|
71
|
+
if (typeof content === 'string') {
|
|
72
|
+
this.element.innerHTML = content;
|
|
73
|
+
} else {
|
|
74
|
+
this.element.replaceChildren(content);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Makes the button act as the key `code` of `keyboard`: pressing it is that key going down,
|
|
80
|
+
* releasing it the key going up.
|
|
81
|
+
*/
|
|
82
|
+
public bindKey(keyboard: KeyboardInput, code: string): this {
|
|
83
|
+
this.changes$.subscribe(pressed => (pressed ? keyboard.emulateKeyDown(code) : keyboard.emulateKeyUp(code)));
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Makes the button push `input` in a direction while pressed, e.g. `{ x: -1 }` for "left".
|
|
89
|
+
*/
|
|
90
|
+
public bindDirection(input: DirectionInput, value: AnalogDirection): this {
|
|
91
|
+
this.changes$.subscribe(pressed => input.setAnalogDirection(this, pressed ? value : null));
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Calls `callback` every time the button becomes pressed. */
|
|
96
|
+
public onPress(callback: () => void): this {
|
|
97
|
+
this.changes$.pipe(filter(pressed => pressed)).subscribe(() => callback());
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Calls `callback` every time the button stops being pressed. */
|
|
102
|
+
public onRelease(callback: () => void): this {
|
|
103
|
+
this.changes$.pipe(filter(pressed => !pressed)).subscribe(() => callback());
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public reset(): void {
|
|
108
|
+
super.reset();
|
|
109
|
+
this.pressed = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public dispose(): void {
|
|
113
|
+
if (this.emitting) {
|
|
114
|
+
// disposed from within a subscriber: the release has to reach every subscriber first
|
|
115
|
+
this.disposeRequested = true;
|
|
116
|
+
this.reset();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
super.dispose();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `pressed$` without the value it replays on subscription. */
|
|
123
|
+
private get changes$(): Observable<boolean> {
|
|
124
|
+
return this.pressed$.pipe(skip(1));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected onPointerStart(): void {
|
|
128
|
+
this.pressed = this.mode === 'toggle' ? !this.pressed : true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected onPointerEnd(): void {
|
|
132
|
+
if (this.mode === 'hold') {
|
|
133
|
+
this.pressed = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Subject } from 'rxjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a control sits inside the overlay and how big it is. A number is a multiple of the overlay's
|
|
5
|
+
* `--gg-mc-unit` (an edge offset also keeps clear of the device's safe-area inset on that side); a
|
|
6
|
+
* string is used as a CSS value as is. Leave `width`/`height` out to keep the size the stylesheet
|
|
7
|
+
* gives that kind of control.
|
|
8
|
+
*/
|
|
9
|
+
export type ControlPlacement = {
|
|
10
|
+
left?: number | string;
|
|
11
|
+
right?: number | string;
|
|
12
|
+
top?: number | string;
|
|
13
|
+
bottom?: number | string;
|
|
14
|
+
width?: number | string;
|
|
15
|
+
height?: number | string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TouchControlOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* Identifies the control within its layout. Becomes the element's `gg-mc-id-<id>` class and
|
|
21
|
+
* `data-gg-mc` attribute, for styling one control from CSS.
|
|
22
|
+
*/
|
|
23
|
+
id?: string;
|
|
24
|
+
placement?: ControlPlacement;
|
|
25
|
+
/** Extra CSS class names for the element, space-separated. */
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Accessible name of the control. */
|
|
28
|
+
label?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const EDGES = ['left', 'right', 'top', 'bottom'] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sets a control element's inline position/size from a `ControlPlacement`.
|
|
35
|
+
*/
|
|
36
|
+
export function applyControlPlacement(element: HTMLElement, placement: ControlPlacement): void {
|
|
37
|
+
for (const edge of EDGES) {
|
|
38
|
+
const value = placement[edge];
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
element.style.setProperty(
|
|
41
|
+
edge,
|
|
42
|
+
typeof value === 'number' ? `calc(var(--gg-mc-unit) * ${value} + env(safe-area-inset-${edge}, 0px))` : value,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const dimension of ['width', 'height'] as const) {
|
|
47
|
+
const value = placement[dimension];
|
|
48
|
+
if (value !== undefined) {
|
|
49
|
+
element.style.setProperty(dimension, typeof value === 'number' ? `calc(var(--gg-mc-unit) * ${value})` : value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The base of every on-screen control: owns one DOM element and follows one pointer on it at a time.
|
|
56
|
+
* A control is plain DOM with no dependency on a world, so it works inside a `MobileControls` overlay
|
|
57
|
+
* and equally in an app's own markup - append `element` anywhere.
|
|
58
|
+
*
|
|
59
|
+
* A pointer that goes down on the control is captured by it and never reaches the elements below
|
|
60
|
+
* (the game canvas, a `MouseInput` listening on the window).
|
|
61
|
+
*/
|
|
62
|
+
export abstract class TouchControl {
|
|
63
|
+
public readonly element: HTMLElement;
|
|
64
|
+
public readonly id: string | undefined;
|
|
65
|
+
|
|
66
|
+
protected readonly disposed$: Subject<void> = new Subject<void>();
|
|
67
|
+
private activePointerId: number | null = null;
|
|
68
|
+
private _disposed: boolean = false;
|
|
69
|
+
|
|
70
|
+
public get disposed(): boolean {
|
|
71
|
+
return this._disposed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether a pointer is currently down on the control. */
|
|
75
|
+
public get touched(): boolean {
|
|
76
|
+
return this.activePointerId !== null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public get visible(): boolean {
|
|
80
|
+
return !this.element.hidden;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Takes the control off the screen (and back) without disposing it, for an action that is not
|
|
85
|
+
* available at the moment. Hiding releases whatever the control holds.
|
|
86
|
+
*/
|
|
87
|
+
public set visible(value: boolean) {
|
|
88
|
+
if (!value) {
|
|
89
|
+
this.reset();
|
|
90
|
+
}
|
|
91
|
+
this.element.hidden = !value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Moves/resizes the control; edges and sizes left out of `placement` stay as they are. */
|
|
95
|
+
public place(placement: ControlPlacement): void {
|
|
96
|
+
applyControlPlacement(this.element, placement);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
protected constructor(kind: string, options: TouchControlOptions) {
|
|
100
|
+
this.id = options.id;
|
|
101
|
+
this.element = document.createElement('div');
|
|
102
|
+
this.element.className = `gg-mc-control gg-mc-${kind}`;
|
|
103
|
+
if (options.id) {
|
|
104
|
+
this.element.classList.add(`gg-mc-id-${options.id}`);
|
|
105
|
+
this.element.dataset.ggMc = options.id;
|
|
106
|
+
}
|
|
107
|
+
if (options.className) {
|
|
108
|
+
this.element.classList.add(...options.className.split(/\s+/).filter(x => !!x));
|
|
109
|
+
}
|
|
110
|
+
if (options.label) {
|
|
111
|
+
this.element.setAttribute('aria-label', options.label);
|
|
112
|
+
}
|
|
113
|
+
if (options.placement) {
|
|
114
|
+
applyControlPlacement(this.element, options.placement);
|
|
115
|
+
}
|
|
116
|
+
this.handlePointerDown = this.handlePointerDown.bind(this);
|
|
117
|
+
this.handlePointerMove = this.handlePointerMove.bind(this);
|
|
118
|
+
this.handlePointerEnd = this.handlePointerEnd.bind(this);
|
|
119
|
+
this.element.addEventListener('pointerdown', this.handlePointerDown);
|
|
120
|
+
this.element.addEventListener('pointermove', this.handlePointerMove);
|
|
121
|
+
this.element.addEventListener('pointerup', this.handlePointerEnd);
|
|
122
|
+
this.element.addEventListener('pointercancel', this.handlePointerEnd);
|
|
123
|
+
this.element.addEventListener('lostpointercapture', this.handlePointerEnd);
|
|
124
|
+
this.element.addEventListener('contextmenu', e => e.preventDefault());
|
|
125
|
+
// stops the long-press text selection/magnifier of iOS, which `touch-action` does not cover
|
|
126
|
+
this.element.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns the control to its untouched state, releasing whatever it holds (a pressed button, a
|
|
131
|
+
* deflected stick). A latched toggle button is released too.
|
|
132
|
+
*/
|
|
133
|
+
public reset(): void {
|
|
134
|
+
if (this.activePointerId !== null) {
|
|
135
|
+
this.releaseCapture(this.activePointerId);
|
|
136
|
+
this.activePointerId = null;
|
|
137
|
+
this.onPointerEnd(null);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resets the control, completes its observables (ending every binding made through it) and removes
|
|
143
|
+
* its element from the document.
|
|
144
|
+
*/
|
|
145
|
+
public dispose(): void {
|
|
146
|
+
if (this._disposed) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.reset();
|
|
150
|
+
this._disposed = true;
|
|
151
|
+
this.disposed$.next();
|
|
152
|
+
this.disposed$.complete();
|
|
153
|
+
this.element.remove();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected abstract onPointerStart(event: PointerEvent): void;
|
|
157
|
+
|
|
158
|
+
protected onPointerMove(event: PointerEvent): void {}
|
|
159
|
+
|
|
160
|
+
/** `event` is `null` when the control was reset rather than released by the pointer. */
|
|
161
|
+
protected abstract onPointerEnd(event: PointerEvent | null): void;
|
|
162
|
+
|
|
163
|
+
private handlePointerDown(event: PointerEvent): void {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
event.stopPropagation();
|
|
166
|
+
if (this.activePointerId !== null || (event.pointerType === 'mouse' && event.button !== 0)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.activePointerId = event.pointerId;
|
|
170
|
+
try {
|
|
171
|
+
this.element.setPointerCapture(event.pointerId);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// not capturable (a synthetic event, a pointer already gone): the control still works while
|
|
174
|
+
// the pointer stays over it
|
|
175
|
+
}
|
|
176
|
+
this.onPointerStart(event);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private handlePointerMove(event: PointerEvent): void {
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
if (event.pointerId === this.activePointerId) {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
this.onPointerMove(event);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private handlePointerEnd(event: PointerEvent): void {
|
|
188
|
+
event.stopPropagation();
|
|
189
|
+
if (event.pointerId !== this.activePointerId) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.activePointerId = null;
|
|
193
|
+
this.releaseCapture(event.pointerId);
|
|
194
|
+
this.onPointerEnd(event);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private releaseCapture(pointerId: number): void {
|
|
198
|
+
try {
|
|
199
|
+
this.element.releasePointerCapture(pointerId);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// was not captured
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Pnt2 } from '@gg-web-engine/core';
|
|
2
|
+
import { TouchControlOptions } from './touch-control';
|
|
3
|
+
import { TouchAxisControl } from './touch-axis-control';
|
|
4
|
+
import { MobileControlsIcons } from '../icons';
|
|
5
|
+
|
|
6
|
+
export type TouchDPadOptions = TouchControlOptions & {
|
|
7
|
+
/** Whether two neighboring directions can be held at once. `true` by default. */
|
|
8
|
+
diagonals?: boolean;
|
|
9
|
+
/** What each arm shows: markup or a DOM node. Arrows by default. */
|
|
10
|
+
content?: { up?: string | Node; down?: string | Node; left?: string | Node; right?: string | Node };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ARMS = ['up', 'down', 'left', 'right'] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An on-screen directional pad: four arms read as one control, so a thumb slides from one direction
|
|
17
|
+
* to another without lifting. Each axis of its value is -1, 0 or 1.
|
|
18
|
+
*/
|
|
19
|
+
export class TouchDPad extends TouchAxisControl {
|
|
20
|
+
public readonly diagonals: boolean;
|
|
21
|
+
|
|
22
|
+
private readonly arms: { [arm in (typeof ARMS)[number]]: HTMLElement };
|
|
23
|
+
|
|
24
|
+
constructor(options: TouchDPadOptions = {}) {
|
|
25
|
+
super('dpad', options);
|
|
26
|
+
this.diagonals = options.diagonals ?? true;
|
|
27
|
+
this.arms = {} as any;
|
|
28
|
+
for (const arm of ARMS) {
|
|
29
|
+
const element = document.createElement('div');
|
|
30
|
+
element.className = `gg-mc-dpad-arm gg-mc-dpad-arm--${arm}`;
|
|
31
|
+
const content = options.content?.[arm] ?? MobileControlsIcons[arm];
|
|
32
|
+
if (typeof content === 'string') {
|
|
33
|
+
element.innerHTML = content;
|
|
34
|
+
} else {
|
|
35
|
+
element.appendChild(content);
|
|
36
|
+
}
|
|
37
|
+
this.arms[arm] = element;
|
|
38
|
+
this.element.appendChild(element);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected onPointerStart(event: PointerEvent): void {
|
|
43
|
+
this.update(event);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
protected onPointerMove(event: PointerEvent): void {
|
|
47
|
+
this.update(event);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected onPointerEnd(): void {
|
|
51
|
+
this.apply(0, 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private update(event: PointerEvent): void {
|
|
55
|
+
const rect = this.element.getBoundingClientRect();
|
|
56
|
+
const radius = rect.width / 2 || 50;
|
|
57
|
+
const offset = {
|
|
58
|
+
x: (event.clientX - (rect.left + rect.width / 2)) / radius,
|
|
59
|
+
y: (event.clientY - (rect.top + rect.height / 2)) / radius,
|
|
60
|
+
};
|
|
61
|
+
if (Pnt2.len(offset) < 0.18) {
|
|
62
|
+
this.apply(0, 0);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// split the circle into 8 sectors (4 without diagonals) and take the one the pointer is in
|
|
66
|
+
const sectors = this.diagonals ? 8 : 4;
|
|
67
|
+
const angle = (Math.round(Math.atan2(-offset.y, offset.x) / ((Math.PI * 2) / sectors)) * Math.PI * 2) / sectors;
|
|
68
|
+
this.apply(Math.round(Math.cos(angle)), Math.round(Math.sin(angle)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private apply(x: number, y: number): void {
|
|
72
|
+
this.arms.up.classList.toggle('gg-mc-active', y > 0);
|
|
73
|
+
this.arms.down.classList.toggle('gg-mc-active', y < 0);
|
|
74
|
+
this.arms.left.classList.toggle('gg-mc-active', x < 0);
|
|
75
|
+
this.arms.right.classList.toggle('gg-mc-active', x > 0);
|
|
76
|
+
this.setValue({ x: x === 0 ? 0 : x, y: y === 0 ? 0 : y });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Observable, Subject, takeUntil } from 'rxjs';
|
|
2
|
+
import { MouseInput, Point2 } from '@gg-web-engine/core';
|
|
3
|
+
import { TouchControl, TouchControlOptions } from './touch-control';
|
|
4
|
+
|
|
5
|
+
export type TouchLookAreaOptions = TouchControlOptions & {
|
|
6
|
+
/** How far, in pixels, a touch may move and still count as a tap. 10 by default. */
|
|
7
|
+
tapDistance?: number;
|
|
8
|
+
/** How long, in milliseconds, a touch may last and still count as a tap. 250 by default. */
|
|
9
|
+
tapDuration?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An invisible area that reports how a finger drags across it - the touch counterpart of moving the
|
|
14
|
+
* mouse to look around. Fills the whole overlay unless placed; put it before the other controls, so
|
|
15
|
+
* they stay on top of it.
|
|
16
|
+
*/
|
|
17
|
+
export class TouchLookArea extends TouchControl {
|
|
18
|
+
private readonly _delta$: Subject<Point2> = new Subject<Point2>();
|
|
19
|
+
private readonly _tap$: Subject<Point2> = new Subject<Point2>();
|
|
20
|
+
private readonly tapDistance: number;
|
|
21
|
+
private readonly tapDuration: number;
|
|
22
|
+
private last: Point2 = { x: 0, y: 0 };
|
|
23
|
+
private start: { x: number; y: number; time: number; moved: boolean } | null = null;
|
|
24
|
+
|
|
25
|
+
/** The movement of the dragging finger since its previous report, in pixels. */
|
|
26
|
+
public get delta$(): Observable<Point2> {
|
|
27
|
+
return this._delta$.pipe(takeUntil(this.disposed$));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A short touch that did not turn into a drag, with its client position. */
|
|
31
|
+
public get tap$(): Observable<Point2> {
|
|
32
|
+
return this._tap$.pipe(takeUntil(this.disposed$));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor(options: TouchLookAreaOptions = {}) {
|
|
36
|
+
super('look-area', options);
|
|
37
|
+
this.tapDistance = options.tapDistance ?? 10;
|
|
38
|
+
this.tapDuration = options.tapDuration ?? 250;
|
|
39
|
+
if (!options.placement) {
|
|
40
|
+
this.element.style.inset = '0';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Makes dragging over the area turn a view the way moving the mouse does: every drag is reported
|
|
46
|
+
* by `mouse` as a movement, multiplied by `sensitivity`.
|
|
47
|
+
*/
|
|
48
|
+
public bindMouse(mouse: MouseInput, sensitivity: number = 1): this {
|
|
49
|
+
this.delta$.subscribe(delta => mouse.emulateMove({ x: delta.x * sensitivity, y: delta.y * sensitivity }));
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected onPointerStart(event: PointerEvent): void {
|
|
54
|
+
this.last = { x: event.clientX, y: event.clientY };
|
|
55
|
+
this.start = { x: event.clientX, y: event.clientY, time: event.timeStamp, moved: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected onPointerMove(event: PointerEvent): void {
|
|
59
|
+
const delta = { x: event.clientX - this.last.x, y: event.clientY - this.last.y };
|
|
60
|
+
this.last = { x: event.clientX, y: event.clientY };
|
|
61
|
+
if (this.start && Math.hypot(event.clientX - this.start.x, event.clientY - this.start.y) > this.tapDistance) {
|
|
62
|
+
this.start.moved = true;
|
|
63
|
+
}
|
|
64
|
+
if (delta.x !== 0 || delta.y !== 0) {
|
|
65
|
+
this._delta$.next(delta);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected onPointerEnd(event: PointerEvent | null): void {
|
|
70
|
+
const start = this.start;
|
|
71
|
+
this.start = null;
|
|
72
|
+
if (event && event.type === 'pointerup' && start && !start.moved) {
|
|
73
|
+
if (event.timeStamp - start.time <= this.tapDuration) {
|
|
74
|
+
this._tap$.next({ x: event.clientX, y: event.clientY });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Pnt2 } from '@gg-web-engine/core';
|
|
2
|
+
import { TouchControlOptions } from './touch-control';
|
|
3
|
+
import { TouchAxisControl } from './touch-axis-control';
|
|
4
|
+
|
|
5
|
+
export type TouchStickOptions = TouchControlOptions & {
|
|
6
|
+
/**
|
|
7
|
+
* `'fixed'` (default): the stick stays where it is placed. `'floating'`: the control is a touch
|
|
8
|
+
* zone (give it a `placement` with a size), and the stick appears under the finger wherever the
|
|
9
|
+
* zone is touched, so the thumb never has to find it.
|
|
10
|
+
*/
|
|
11
|
+
mode?: 'fixed' | 'floating';
|
|
12
|
+
/** Which axes the stick moves along. `'both'` by default. */
|
|
13
|
+
axes?: 'both' | 'x' | 'y';
|
|
14
|
+
/** The fraction of the travel around the center that reads as zero. 0.12 by default. */
|
|
15
|
+
deadzone?: number;
|
|
16
|
+
/**
|
|
17
|
+
* How far the knob travels from the center at full deflection, in pixels. By default 36% of the
|
|
18
|
+
* rendered width of the base.
|
|
19
|
+
*/
|
|
20
|
+
travel?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An on-screen analog stick.
|
|
25
|
+
*/
|
|
26
|
+
export class TouchStick extends TouchAxisControl {
|
|
27
|
+
public readonly mode: 'fixed' | 'floating';
|
|
28
|
+
public readonly axes: 'both' | 'x' | 'y';
|
|
29
|
+
public readonly deadzone: number;
|
|
30
|
+
|
|
31
|
+
private readonly base: HTMLElement;
|
|
32
|
+
private readonly knob: HTMLElement;
|
|
33
|
+
private readonly travelOption: number | undefined;
|
|
34
|
+
private center = Pnt2.O;
|
|
35
|
+
private travel: number = 1;
|
|
36
|
+
|
|
37
|
+
constructor(options: TouchStickOptions = {}) {
|
|
38
|
+
super('stick', options);
|
|
39
|
+
this.mode = options.mode || 'fixed';
|
|
40
|
+
this.axes = options.axes || 'both';
|
|
41
|
+
this.deadzone = options.deadzone ?? 0.12;
|
|
42
|
+
this.travelOption = options.travel;
|
|
43
|
+
this.element.classList.add(`gg-mc-stick--${this.mode}`);
|
|
44
|
+
this.base = document.createElement('div');
|
|
45
|
+
this.base.className = 'gg-mc-stick-base';
|
|
46
|
+
this.knob = document.createElement('div');
|
|
47
|
+
this.knob.className = 'gg-mc-stick-knob';
|
|
48
|
+
this.base.appendChild(this.knob);
|
|
49
|
+
this.element.appendChild(this.base);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
protected onPointerStart(event: PointerEvent): void {
|
|
53
|
+
this.element.classList.add('gg-mc-active');
|
|
54
|
+
if (this.mode === 'floating') {
|
|
55
|
+
const zone = this.element.getBoundingClientRect();
|
|
56
|
+
this.base.style.left = `${event.clientX - zone.left}px`;
|
|
57
|
+
this.base.style.top = `${event.clientY - zone.top}px`;
|
|
58
|
+
this.center = { x: event.clientX, y: event.clientY };
|
|
59
|
+
}
|
|
60
|
+
const rect = this.base.getBoundingClientRect();
|
|
61
|
+
if (this.mode === 'fixed') {
|
|
62
|
+
this.center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
63
|
+
}
|
|
64
|
+
this.travel = this.travelOption ?? (rect.width * 0.36 || 50);
|
|
65
|
+
this.update(event);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected onPointerMove(event: PointerEvent): void {
|
|
69
|
+
this.update(event);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected onPointerEnd(): void {
|
|
73
|
+
this.element.classList.remove('gg-mc-active');
|
|
74
|
+
this.base.style.left = '';
|
|
75
|
+
this.base.style.top = '';
|
|
76
|
+
this.knob.style.transform = '';
|
|
77
|
+
this.setValue(Pnt2.O);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private update(event: PointerEvent): void {
|
|
81
|
+
let offset = { x: (event.clientX - this.center.x) / this.travel, y: (event.clientY - this.center.y) / this.travel };
|
|
82
|
+
if (this.axes === 'x') {
|
|
83
|
+
offset.y = 0;
|
|
84
|
+
} else if (this.axes === 'y') {
|
|
85
|
+
offset.x = 0;
|
|
86
|
+
}
|
|
87
|
+
const length = Pnt2.len(offset);
|
|
88
|
+
if (length > 1) {
|
|
89
|
+
offset = Pnt2.scalarMult(offset, 1 / length);
|
|
90
|
+
}
|
|
91
|
+
this.knob.style.transform = `translate(-50%, -50%) translate(${offset.x * this.travel}px, ${offset.y * this.travel}px)`;
|
|
92
|
+
if (length <= this.deadzone) {
|
|
93
|
+
this.setValue(Pnt2.O);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// rescaled so the value starts from 0 right outside the deadzone instead of jumping to it
|
|
97
|
+
const scale = (Math.min(1, length) - this.deadzone) / (1 - this.deadzone) / Math.min(1, length);
|
|
98
|
+
this.setValue({ x: offset.x * scale, y: offset.y === 0 ? 0 : -offset.y * scale });
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const svg = (body: string) => `<svg viewBox="0 0 24 24" aria-hidden="true">${body}</svg>`;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The built-in icons, as inline SVG markup drawn with `currentColor`. Pass any of them (or any other
|
|
5
|
+
* markup, or a DOM node) as a `TouchButton`'s `content`.
|
|
6
|
+
*/
|
|
7
|
+
export const MobileControlsIcons = {
|
|
8
|
+
left: svg('<path d="M15 5l-7 7 7 7"/>'),
|
|
9
|
+
right: svg('<path d="M9 5l7 7-7 7"/>'),
|
|
10
|
+
up: svg('<path d="M5 15l7-7 7 7"/>'),
|
|
11
|
+
down: svg('<path d="M5 9l7 7 7-7"/>'),
|
|
12
|
+
accelerate: svg('<path d="M6 12l6-6 6 6M6 19l6-6 6 6"/>'),
|
|
13
|
+
brake: svg('<path d="M6 5l6 6 6-6M6 12l6 6 6-6"/>'),
|
|
14
|
+
handbrake: svg(
|
|
15
|
+
'<circle cx="12" cy="12" r="6"/><path d="M4.5 6a10 10 0 0 0 0 12M19.5 6a10 10 0 0 1 0 12M10.5 15V9h1.75a1.75 1.75 0 0 1 0 3.5H10.5"/>',
|
|
16
|
+
),
|
|
17
|
+
plus: svg('<path d="M12 5v14M5 12h14"/>'),
|
|
18
|
+
minus: svg('<path d="M5 12h14"/>'),
|
|
19
|
+
jump: svg('<path d="M12 16V4M7 9l5-5 5 5M5 20h14"/>'),
|
|
20
|
+
crouch: svg('<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>'),
|
|
21
|
+
run: svg('<path d="M5 6l6 6-6 6M13 6l6 6-6 6"/>'),
|
|
22
|
+
view: svg('<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
23
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from './controls/touch-control';
|
|
2
|
+
export * from './controls/touch-button';
|
|
3
|
+
export * from './controls/touch-axis-control';
|
|
4
|
+
export * from './controls/touch-stick';
|
|
5
|
+
export * from './controls/touch-dpad';
|
|
6
|
+
export * from './controls/touch-look-area';
|
|
7
|
+
export * from './inputs/tilt.input';
|
|
8
|
+
export * from './layouts/car.layout';
|
|
9
|
+
export * from './layouts/character.layout';
|
|
10
|
+
export * from './layouts/character-2d.layout';
|
|
11
|
+
export * from './layouts/free-camera.layout';
|
|
12
|
+
export * from './icons';
|
|
13
|
+
export * from './styles';
|
|
14
|
+
export * from './mobile-controls-layout';
|
|
15
|
+
export * from './mobile-controls.entity';
|