@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,128 @@
|
|
|
1
|
+
import { BehaviorSubject, Observable } from 'rxjs';
|
|
2
|
+
import { distinctUntilChanged } from 'rxjs/operators';
|
|
3
|
+
import { IInput } from '@gg-web-engine/core';
|
|
4
|
+
|
|
5
|
+
export type TiltInputOptions = {
|
|
6
|
+
/** The tilt, in degrees, that reads as full deflection. 30 by default. */
|
|
7
|
+
maxAngle: number;
|
|
8
|
+
/** The tilt, in degrees, around level that reads as zero. 2 by default. */
|
|
9
|
+
deadzone: number;
|
|
10
|
+
/** Flips the sign of the output. `false` by default. */
|
|
11
|
+
invert: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPTIONS: TiltInputOptions = { maxAngle: 30, deadzone: 2, invert: false };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An input reading how the device is tilted sideways, like a steering wheel: `value$` goes from -1
|
|
18
|
+
* (the left edge of the screen is the lower one) to 1 (the right edge is), whichever way the screen
|
|
19
|
+
* is currently rotated and whether the device is held upright or lies flat.
|
|
20
|
+
*
|
|
21
|
+
* Orientation data is available to pages served over https (or from localhost) only; anywhere else
|
|
22
|
+
* `permission$` reports `'denied'`.
|
|
23
|
+
*
|
|
24
|
+
* Some browsers (iOS Safari) only hand out orientation data after the user allowed it in a prompt
|
|
25
|
+
* that has to be opened from a user gesture. `start()` takes care of that: when a permission is
|
|
26
|
+
* needed, it asks on the next tap anywhere on the page and starts reporting once granted.
|
|
27
|
+
* `permission$` tells how that went, for an app that wants to explain itself first or to fall back
|
|
28
|
+
* to another control when denied.
|
|
29
|
+
*/
|
|
30
|
+
export class TiltInput extends IInput {
|
|
31
|
+
/** Whether the browser reports device orientation at all. */
|
|
32
|
+
static isSupported(): boolean {
|
|
33
|
+
return typeof window !== 'undefined' && 'DeviceOrientationEvent' in window;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public readonly options: TiltInputOptions;
|
|
37
|
+
|
|
38
|
+
private readonly _value$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
|
|
39
|
+
private readonly _permission$: BehaviorSubject<'unknown' | 'granted' | 'denied'> = new BehaviorSubject<
|
|
40
|
+
'unknown' | 'granted' | 'denied'
|
|
41
|
+
>('unknown');
|
|
42
|
+
|
|
43
|
+
/** Emits the current value on subscription and then every change. */
|
|
44
|
+
public get value$(): Observable<number> {
|
|
45
|
+
return this._value$.pipe(distinctUntilChanged());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public get value(): number {
|
|
49
|
+
return this._value$.getValue();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public get permission$(): Observable<'unknown' | 'granted' | 'denied'> {
|
|
53
|
+
return this._permission$.pipe(distinctUntilChanged());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
constructor(options: Partial<TiltInputOptions> = {}) {
|
|
57
|
+
super();
|
|
58
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
59
|
+
this.handleOrientation = this.handleOrientation.bind(this);
|
|
60
|
+
this.requestPermission = this.requestPermission.bind(this);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected startInternal(): void {
|
|
64
|
+
if (!TiltInput.isSupported()) {
|
|
65
|
+
this._permission$.next('denied');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (window.isSecureContext === false) {
|
|
69
|
+
// browsers hand out orientation data to pages served over https (or from localhost) only, and
|
|
70
|
+
// on other pages stay silent instead of failing
|
|
71
|
+
console.warn('[TiltInput] device orientation is not available: the page is not served over https');
|
|
72
|
+
this._permission$.next('denied');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const needsPermission = typeof (DeviceOrientationEvent as any).requestPermission === 'function';
|
|
76
|
+
if (needsPermission && this._permission$.getValue() !== 'granted') {
|
|
77
|
+
// the prompt only opens from a handler of a finished gesture, not from a touch going down
|
|
78
|
+
window.addEventListener('touchend', this.requestPermission);
|
|
79
|
+
window.addEventListener('click', this.requestPermission);
|
|
80
|
+
} else {
|
|
81
|
+
this._permission$.next('granted');
|
|
82
|
+
window.addEventListener('deviceorientation', this.handleOrientation);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
protected stopInternal(): void {
|
|
87
|
+
window.removeEventListener('touchend', this.requestPermission);
|
|
88
|
+
window.removeEventListener('click', this.requestPermission);
|
|
89
|
+
window.removeEventListener('deviceorientation', this.handleOrientation);
|
|
90
|
+
this._value$.next(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private requestPermission(): void {
|
|
94
|
+
window.removeEventListener('touchend', this.requestPermission);
|
|
95
|
+
window.removeEventListener('click', this.requestPermission);
|
|
96
|
+
(DeviceOrientationEvent as any)
|
|
97
|
+
.requestPermission()
|
|
98
|
+
.then((state: string) => {
|
|
99
|
+
this._permission$.next(state === 'granted' ? 'granted' : 'denied');
|
|
100
|
+
if (state === 'granted' && this.running) {
|
|
101
|
+
window.addEventListener('deviceorientation', this.handleOrientation);
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.catch(() => this._permission$.next('denied'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private handleOrientation(event: DeviceOrientationEvent): void {
|
|
108
|
+
if (event.beta === null || event.gamma === null) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const beta = (event.beta * Math.PI) / 180;
|
|
112
|
+
const gamma = (event.gamma * Math.PI) / 180;
|
|
113
|
+
// the direction of gravity within the plane of the screen, in the device's own (portrait) axes:
|
|
114
|
+
// x towards its right edge, y towards its top edge
|
|
115
|
+
const gravityX = Math.cos(beta) * Math.sin(gamma);
|
|
116
|
+
const gravityY = -Math.sin(beta);
|
|
117
|
+
// how much of it points towards the right edge of the screen as currently rotated
|
|
118
|
+
const screenAngle = ((screen.orientation?.angle ?? (window as any).orientation ?? 0) * Math.PI) / 180;
|
|
119
|
+
const right = gravityX * Math.cos(screenAngle) - gravityY * Math.sin(screenAngle);
|
|
120
|
+
const degrees = (Math.asin(Math.max(-1, Math.min(1, right))) * 180) / Math.PI;
|
|
121
|
+
const { maxAngle, deadzone, invert } = this.options;
|
|
122
|
+
let value = 0;
|
|
123
|
+
if (Math.abs(degrees) > deadzone) {
|
|
124
|
+
value = Math.sign(degrees) * Math.min(1, (Math.abs(degrees) - deadzone) / (maxAngle - deadzone));
|
|
125
|
+
}
|
|
126
|
+
this._value$.next(invert && value !== 0 ? -value : value);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { CarHandlingController, GgCarHandlingController } from '@gg-web-engine/core';
|
|
2
|
+
import { TouchButton } from '../controls/touch-button';
|
|
3
|
+
import { TouchStick } from '../controls/touch-stick';
|
|
4
|
+
import { MobileControlsIcons } from '../icons';
|
|
5
|
+
import { TiltInput, TiltInputOptions } from '../inputs/tilt.input';
|
|
6
|
+
import { LayoutBuilder, LayoutCustomization, MobileControlsLayoutFactory } from '../mobile-controls-layout';
|
|
7
|
+
|
|
8
|
+
export type CarLayoutControlId =
|
|
9
|
+
'steer-left' | 'steer-right' | 'steer-stick' | 'accelerate' | 'brake' | 'handbrake' | 'gear-up' | 'gear-down';
|
|
10
|
+
|
|
11
|
+
/** The controllers `carLayout` builds controls for. */
|
|
12
|
+
export type CarLayoutController = GgCarHandlingController | CarHandlingController;
|
|
13
|
+
|
|
14
|
+
export type CarLayoutOptions = LayoutCustomization<CarLayoutController, CarLayoutControlId> & {
|
|
15
|
+
/**
|
|
16
|
+
* How the car is steered: `'buttons'` (default) - a left and a right button, `'stick'` - an analog
|
|
17
|
+
* stick moving sideways, `'tilt'` - by tilting the device like a steering wheel (see `TiltInput`).
|
|
18
|
+
*/
|
|
19
|
+
steering?: 'buttons' | 'stick' | 'tilt';
|
|
20
|
+
/** Options of the `TiltInput` behind `steering: 'tilt'`. */
|
|
21
|
+
tilt?: Partial<TiltInputOptions>;
|
|
22
|
+
/** Whether there is a handbrake button (`GgCarHandlingController` only). `true` by default. */
|
|
23
|
+
handbrake?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether there are gear up/down buttons (`GgCarHandlingController` only). `'auto'`
|
|
26
|
+
* (default) shows them when the driver has to
|
|
27
|
+
* shift: gear switching is enabled on the controller, and the car has a manual gearbox or the
|
|
28
|
+
* controller does not pick reverse by itself (`autoReverse`).
|
|
29
|
+
*/
|
|
30
|
+
gears?: boolean | 'auto';
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The built-in layout for the car controllers: steering on the left, pedals on the right. For a
|
|
35
|
+
* `GgCarHandlingController` also the handbrake and the gears; a bare
|
|
36
|
+
* `CarHandlingController` (the steering/throttle half, usable with any vehicle) gets the
|
|
37
|
+
* steering and the pedals alone.
|
|
38
|
+
*/
|
|
39
|
+
export function carLayout(options: CarLayoutOptions = {}): MobileControlsLayoutFactory<CarLayoutController> {
|
|
40
|
+
return (controller, context) => {
|
|
41
|
+
if (controller.parent instanceof GgCarHandlingController) {
|
|
42
|
+
// the half of a `GgCarHandlingController`, whose own layout already covers it
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const b = new LayoutBuilder(options);
|
|
46
|
+
const ggCar = controller instanceof GgCarHandlingController ? controller : null;
|
|
47
|
+
const direction = (ggCar ? ggCar.carHandlingInput : (controller as CarHandlingController)).directionsInput;
|
|
48
|
+
const steering = options.steering || 'buttons';
|
|
49
|
+
if (steering === 'buttons') {
|
|
50
|
+
b.add('steer-left', () =>
|
|
51
|
+
new TouchButton({
|
|
52
|
+
id: 'steer-left',
|
|
53
|
+
label: 'Steer left',
|
|
54
|
+
content: b.icon('steer-left', MobileControlsIcons.left),
|
|
55
|
+
placement: b.placement('steer-left', { left: 4, bottom: 4, width: 10, height: 10 }),
|
|
56
|
+
}).bindDirection(direction, { x: -1 }),
|
|
57
|
+
);
|
|
58
|
+
b.add('steer-right', () =>
|
|
59
|
+
new TouchButton({
|
|
60
|
+
id: 'steer-right',
|
|
61
|
+
label: 'Steer right',
|
|
62
|
+
content: b.icon('steer-right', MobileControlsIcons.right),
|
|
63
|
+
placement: b.placement('steer-right', { left: 16, bottom: 4, width: 10, height: 10 }),
|
|
64
|
+
}).bindDirection(direction, { x: 1 }),
|
|
65
|
+
);
|
|
66
|
+
} else if (steering === 'stick') {
|
|
67
|
+
b.add('steer-stick', () =>
|
|
68
|
+
new TouchStick({
|
|
69
|
+
id: 'steer-stick',
|
|
70
|
+
label: 'Steering',
|
|
71
|
+
axes: 'x',
|
|
72
|
+
placement: b.placement('steer-stick', { left: 4, bottom: 3 }),
|
|
73
|
+
}).bindDirection(direction),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
b.add('accelerate', () =>
|
|
77
|
+
new TouchButton({
|
|
78
|
+
id: 'accelerate',
|
|
79
|
+
label: 'Accelerate',
|
|
80
|
+
content: b.icon('accelerate', MobileControlsIcons.accelerate),
|
|
81
|
+
placement: b.placement('accelerate', { right: 4, bottom: 4, width: 10, height: 10 }),
|
|
82
|
+
}).bindDirection(direction, { y: 1 }),
|
|
83
|
+
);
|
|
84
|
+
b.add('brake', () =>
|
|
85
|
+
new TouchButton({
|
|
86
|
+
id: 'brake',
|
|
87
|
+
label: 'Brake',
|
|
88
|
+
content: b.icon('brake', MobileControlsIcons.brake),
|
|
89
|
+
placement: b.placement('brake', { right: 16, bottom: 4, width: 10, height: 10 }),
|
|
90
|
+
}).bindDirection(direction, { y: -1 }),
|
|
91
|
+
);
|
|
92
|
+
if (ggCar && (options.handbrake ?? true)) {
|
|
93
|
+
b.add('handbrake', () =>
|
|
94
|
+
new TouchButton({
|
|
95
|
+
id: 'handbrake',
|
|
96
|
+
label: 'Handbrake',
|
|
97
|
+
content: b.icon('handbrake', MobileControlsIcons.handbrake),
|
|
98
|
+
placement: b.placement('handbrake', { right: 5.5, bottom: 16, width: 7, height: 7 }),
|
|
99
|
+
}).bindKey(ggCar.keyboard, ggCar.options.handbrakeKey),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
let gears = options.gears ?? 'auto';
|
|
103
|
+
if (ggCar && gears === 'auto') {
|
|
104
|
+
const isAutoGearbox = !!ggCar.car?.carProperties.transmission.isAuto;
|
|
105
|
+
gears = ggCar.switchingGearsEnabled && !(isAutoGearbox && ggCar.options.autoReverse);
|
|
106
|
+
}
|
|
107
|
+
if (ggCar && gears) {
|
|
108
|
+
b.add('gear-down', () =>
|
|
109
|
+
new TouchButton({
|
|
110
|
+
id: 'gear-down',
|
|
111
|
+
label: 'Gear down',
|
|
112
|
+
content: b.icon('gear-down', MobileControlsIcons.minus),
|
|
113
|
+
placement: b.placement('gear-down', { left: 5.5, bottom: 16, width: 7, height: 7 }),
|
|
114
|
+
}).bindKey(ggCar.keyboard, ggCar.options.gearUpDownKeys[1]),
|
|
115
|
+
);
|
|
116
|
+
b.add('gear-up', () =>
|
|
117
|
+
new TouchButton({
|
|
118
|
+
id: 'gear-up',
|
|
119
|
+
label: 'Gear up',
|
|
120
|
+
content: b.icon('gear-up', MobileControlsIcons.plus),
|
|
121
|
+
placement: b.placement('gear-up', { left: 17.5, bottom: 16, width: 7, height: 7 }),
|
|
122
|
+
}).bindKey(ggCar.keyboard, ggCar.options.gearUpDownKeys[0]),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (steering === 'tilt') {
|
|
126
|
+
const tilt = new TiltInput(options.tilt);
|
|
127
|
+
const subscription = tilt.value$.subscribe(x => direction.setAnalogDirection(tilt, x === 0 ? null : { x }));
|
|
128
|
+
tilt.start();
|
|
129
|
+
b.result.push({
|
|
130
|
+
dispose: () => {
|
|
131
|
+
subscription.unsubscribe();
|
|
132
|
+
direction.setAnalogDirection(tilt, null);
|
|
133
|
+
tilt.stop();
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return b.finish(controller, context);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { PlayerCharacterController2d } from '@gg-web-engine/core';
|
|
2
|
+
import { TouchButton } from '../controls/touch-button';
|
|
3
|
+
import { TouchStick } from '../controls/touch-stick';
|
|
4
|
+
import { MobileControlsIcons } from '../icons';
|
|
5
|
+
import { LayoutBuilder, LayoutCustomization, MobileControlsLayoutFactory } from '../mobile-controls-layout';
|
|
6
|
+
|
|
7
|
+
export type Character2dLayoutControlId = 'move-left' | 'move-right' | 'move' | 'jump' | 'run' | 'crouch';
|
|
8
|
+
|
|
9
|
+
export type Character2dLayoutOptions = LayoutCustomization<
|
|
10
|
+
PlayerCharacterController2d<any>,
|
|
11
|
+
Character2dLayoutControlId
|
|
12
|
+
> & {
|
|
13
|
+
/**
|
|
14
|
+
* How the character is moved: `'buttons'` (default) - a left and a right button, `'stick'` - an
|
|
15
|
+
* analog stick moving sideways.
|
|
16
|
+
*/
|
|
17
|
+
movement?: 'buttons' | 'stick';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The built-in layout for `PlayerCharacterController2d`: left/right on the left, actions on the
|
|
22
|
+
* right.
|
|
23
|
+
*/
|
|
24
|
+
export function character2dLayout(
|
|
25
|
+
options: Character2dLayoutOptions = {},
|
|
26
|
+
): MobileControlsLayoutFactory<PlayerCharacterController2d<any>> {
|
|
27
|
+
return (controller, context) => {
|
|
28
|
+
const b = new LayoutBuilder(options);
|
|
29
|
+
const direction = controller.directionsInput;
|
|
30
|
+
if ((options.movement || 'buttons') === 'buttons') {
|
|
31
|
+
b.add('move-left', () =>
|
|
32
|
+
new TouchButton({
|
|
33
|
+
id: 'move-left',
|
|
34
|
+
label: 'Move left',
|
|
35
|
+
content: b.icon('move-left', MobileControlsIcons.left),
|
|
36
|
+
placement: b.placement('move-left', { left: 4, bottom: 4, width: 10, height: 10 }),
|
|
37
|
+
}).bindDirection(direction, { x: -1 }),
|
|
38
|
+
);
|
|
39
|
+
b.add('move-right', () =>
|
|
40
|
+
new TouchButton({
|
|
41
|
+
id: 'move-right',
|
|
42
|
+
label: 'Move right',
|
|
43
|
+
content: b.icon('move-right', MobileControlsIcons.right),
|
|
44
|
+
placement: b.placement('move-right', { left: 16, bottom: 4, width: 10, height: 10 }),
|
|
45
|
+
}).bindDirection(direction, { x: 1 }),
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
b.add('move', () =>
|
|
49
|
+
new TouchStick({
|
|
50
|
+
id: 'move',
|
|
51
|
+
label: 'Move',
|
|
52
|
+
axes: 'x',
|
|
53
|
+
placement: b.placement('move', { left: 4, bottom: 3 }),
|
|
54
|
+
}).bindDirection(direction),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
b.add('jump', () =>
|
|
58
|
+
new TouchButton({
|
|
59
|
+
id: 'jump',
|
|
60
|
+
label: 'Jump',
|
|
61
|
+
content: b.icon('jump', MobileControlsIcons.jump),
|
|
62
|
+
placement: b.placement('jump', { right: 4, bottom: 4, width: 10, height: 10 }),
|
|
63
|
+
}).bindKey(controller.keyboard, controller.options.jumpKey),
|
|
64
|
+
);
|
|
65
|
+
b.add('run', () =>
|
|
66
|
+
new TouchButton({
|
|
67
|
+
id: 'run',
|
|
68
|
+
label: 'Run',
|
|
69
|
+
mode: 'toggle',
|
|
70
|
+
content: b.icon('run', MobileControlsIcons.run),
|
|
71
|
+
placement: b.placement('run', { right: 16, bottom: 4, width: 8, height: 8 }),
|
|
72
|
+
}).bindKey(controller.keyboard, controller.options.runKey),
|
|
73
|
+
);
|
|
74
|
+
b.add('crouch', () =>
|
|
75
|
+
new TouchButton({
|
|
76
|
+
id: 'crouch',
|
|
77
|
+
label: 'Crouch',
|
|
78
|
+
mode: controller.character?.options.crouchMode === 'toggle' ? 'hold' : 'toggle',
|
|
79
|
+
content: b.icon('crouch', MobileControlsIcons.crouch),
|
|
80
|
+
placement: b.placement('crouch', { right: 5, bottom: 16, width: 8, height: 8 }),
|
|
81
|
+
}).bindKey(controller.keyboard, controller.options.crouchKey),
|
|
82
|
+
);
|
|
83
|
+
return b.finish(controller, context);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { PlayerCharacterController } from '@gg-web-engine/core';
|
|
2
|
+
import { TouchButton } from '../controls/touch-button';
|
|
3
|
+
import { TouchDPad } from '../controls/touch-dpad';
|
|
4
|
+
import { TouchLookArea } from '../controls/touch-look-area';
|
|
5
|
+
import { TouchStick } from '../controls/touch-stick';
|
|
6
|
+
import { MobileControlsIcons } from '../icons';
|
|
7
|
+
import { LayoutBuilder, LayoutCustomization, MobileControlsLayoutFactory } from '../mobile-controls-layout';
|
|
8
|
+
|
|
9
|
+
export type CharacterLayoutControlId = 'look' | 'look-stick' | 'move' | 'jump' | 'run' | 'crouch' | 'view';
|
|
10
|
+
|
|
11
|
+
export type CharacterLayoutOptions = LayoutCustomization<PlayerCharacterController<any>, CharacterLayoutControlId> & {
|
|
12
|
+
/**
|
|
13
|
+
* How the character is moved: `'stick'` (default) - an analog stick that appears under the left
|
|
14
|
+
* thumb wherever it lands, `'dpad'` - a directional pad in the corner.
|
|
15
|
+
*/
|
|
16
|
+
movement?: 'stick' | 'dpad';
|
|
17
|
+
/**
|
|
18
|
+
* How the view is turned: `'drag'` (default) - by dragging anywhere the other controls are not,
|
|
19
|
+
* `'stick'` - with a second stick on the right, `false` - not by this layout.
|
|
20
|
+
*/
|
|
21
|
+
look?: 'drag' | 'stick' | false;
|
|
22
|
+
/**
|
|
23
|
+
* Multiplies the turning: of a drag, on top of the controller's `mouseSensitivity` (3 by default,
|
|
24
|
+
* a finger covers much less distance than a mouse), or of the look stick's speed.
|
|
25
|
+
*/
|
|
26
|
+
lookSensitivity?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The built-in layout for `PlayerCharacterController`: movement on the left, actions on the right,
|
|
31
|
+
* looking around by dragging over the rest of the screen.
|
|
32
|
+
*/
|
|
33
|
+
export function characterLayout(
|
|
34
|
+
options: CharacterLayoutOptions = {},
|
|
35
|
+
): MobileControlsLayoutFactory<PlayerCharacterController<any>> {
|
|
36
|
+
return (controller, context) => {
|
|
37
|
+
const b = new LayoutBuilder(options);
|
|
38
|
+
const look = options.look ?? 'drag';
|
|
39
|
+
if (look === 'drag') {
|
|
40
|
+
b.add('look', () =>
|
|
41
|
+
new TouchLookArea({
|
|
42
|
+
id: 'look',
|
|
43
|
+
placement: b.placement('look', { left: '0', top: '0', width: '100%', height: '100%' }),
|
|
44
|
+
}).bindMouse(controller.mouseInput, options.lookSensitivity ?? 3),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if ((options.movement || 'stick') === 'stick') {
|
|
48
|
+
b.add('move', () =>
|
|
49
|
+
new TouchStick({
|
|
50
|
+
id: 'move',
|
|
51
|
+
label: 'Move',
|
|
52
|
+
mode: 'floating',
|
|
53
|
+
placement: b.placement('move', { left: '0', bottom: '0', width: '45%', height: '75%' }),
|
|
54
|
+
}).bindDirection(controller.directionsInput),
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
b.add('move', () =>
|
|
58
|
+
new TouchDPad({
|
|
59
|
+
id: 'move',
|
|
60
|
+
label: 'Move',
|
|
61
|
+
placement: b.placement('move', { left: 4, bottom: 4 }),
|
|
62
|
+
}).bindDirection(controller.directionsInput),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// with a look stick in the corner the action buttons move into a row above it
|
|
66
|
+
const stick = look === 'stick';
|
|
67
|
+
if (stick) {
|
|
68
|
+
b.add('look-stick', () =>
|
|
69
|
+
new TouchStick({
|
|
70
|
+
id: 'look-stick',
|
|
71
|
+
label: 'Look',
|
|
72
|
+
placement: b.placement('look-stick', { right: 4, bottom: 3 }),
|
|
73
|
+
}).bindLook(controller.mouseInput, 900 * (options.lookSensitivity ?? 1)),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
b.add('jump', () =>
|
|
77
|
+
new TouchButton({
|
|
78
|
+
id: 'jump',
|
|
79
|
+
label: 'Jump',
|
|
80
|
+
content: b.icon('jump', MobileControlsIcons.jump),
|
|
81
|
+
placement: b.placement(
|
|
82
|
+
'jump',
|
|
83
|
+
stick ? { right: 4, bottom: 20, width: 8, height: 8 } : { right: 4, bottom: 5, width: 10, height: 10 },
|
|
84
|
+
),
|
|
85
|
+
}).bindKey(controller.keyboard, controller.options.jumpKey),
|
|
86
|
+
);
|
|
87
|
+
b.add('run', () =>
|
|
88
|
+
new TouchButton({
|
|
89
|
+
id: 'run',
|
|
90
|
+
label: 'Run',
|
|
91
|
+
mode: 'toggle',
|
|
92
|
+
content: b.icon('run', MobileControlsIcons.run),
|
|
93
|
+
placement: b.placement(
|
|
94
|
+
'run',
|
|
95
|
+
stick ? { right: 14, bottom: 20, width: 8, height: 8 } : { right: 16, bottom: 4, width: 8, height: 8 },
|
|
96
|
+
),
|
|
97
|
+
}).bindKey(controller.keyboard, controller.options.runKey),
|
|
98
|
+
);
|
|
99
|
+
b.add('crouch', () =>
|
|
100
|
+
new TouchButton({
|
|
101
|
+
id: 'crouch',
|
|
102
|
+
label: 'Crouch',
|
|
103
|
+
// a crouch that lasts while its key is held gets a latching button instead of a parked finger
|
|
104
|
+
mode: controller.character?.options.crouchMode === 'toggle' ? 'hold' : 'toggle',
|
|
105
|
+
content: b.icon('crouch', MobileControlsIcons.crouch),
|
|
106
|
+
placement: b.placement(
|
|
107
|
+
'crouch',
|
|
108
|
+
stick ? { right: 24, bottom: 20, width: 8, height: 8 } : { right: 5, bottom: 17, width: 8, height: 8 },
|
|
109
|
+
),
|
|
110
|
+
}).bindKey(controller.keyboard, controller.options.crouchKey),
|
|
111
|
+
);
|
|
112
|
+
const toggleViewKey = controller.options.toggleViewKey;
|
|
113
|
+
if (toggleViewKey) {
|
|
114
|
+
b.add('view', () =>
|
|
115
|
+
new TouchButton({
|
|
116
|
+
id: 'view',
|
|
117
|
+
label: 'Switch view',
|
|
118
|
+
content: b.icon('view', MobileControlsIcons.view),
|
|
119
|
+
placement: b.placement('view', { right: 4, top: 4, width: 6.5, height: 6.5 }),
|
|
120
|
+
}).bindKey(controller.keyboard, toggleViewKey),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return b.finish(controller, context);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { FreeCameraController } from '@gg-web-engine/core';
|
|
2
|
+
import { TouchButton } from '../controls/touch-button';
|
|
3
|
+
import { TouchDPad } from '../controls/touch-dpad';
|
|
4
|
+
import { TouchLookArea } from '../controls/touch-look-area';
|
|
5
|
+
import { TouchStick } from '../controls/touch-stick';
|
|
6
|
+
import { MobileControlsIcons } from '../icons';
|
|
7
|
+
import { LayoutBuilder, LayoutCustomization, MobileControlsLayoutFactory } from '../mobile-controls-layout';
|
|
8
|
+
|
|
9
|
+
export type FreeCameraLayoutControlId = 'look' | 'look-stick' | 'move' | 'ascend' | 'descend' | 'boost';
|
|
10
|
+
|
|
11
|
+
export type FreeCameraLayoutOptions = LayoutCustomization<FreeCameraController, FreeCameraLayoutControlId> & {
|
|
12
|
+
/** How the camera is moved - see `CharacterLayoutOptions.movement`. `'stick'` by default. */
|
|
13
|
+
movement?: 'stick' | 'dpad';
|
|
14
|
+
/** How the camera is turned - see `CharacterLayoutOptions.look`. `'drag'` by default. */
|
|
15
|
+
look?: 'drag' | 'stick' | false;
|
|
16
|
+
/** Multiplies the turning - see `CharacterLayoutOptions.lookSensitivity`. */
|
|
17
|
+
lookSensitivity?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The built-in layout for `FreeCameraController`: flying on the left, up/down/boost on the right,
|
|
22
|
+
* looking around by dragging over the rest of the screen.
|
|
23
|
+
*/
|
|
24
|
+
export function freeCameraLayout(
|
|
25
|
+
options: FreeCameraLayoutOptions = {},
|
|
26
|
+
): MobileControlsLayoutFactory<FreeCameraController> {
|
|
27
|
+
return (controller, context) => {
|
|
28
|
+
const b = new LayoutBuilder(options);
|
|
29
|
+
const look = options.look ?? 'drag';
|
|
30
|
+
if (look === 'drag') {
|
|
31
|
+
b.add('look', () =>
|
|
32
|
+
new TouchLookArea({
|
|
33
|
+
id: 'look',
|
|
34
|
+
placement: b.placement('look', { left: '0', top: '0', width: '100%', height: '100%' }),
|
|
35
|
+
}).bindMouse(controller.mouseInput, options.lookSensitivity ?? 3),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if ((options.movement || 'stick') === 'stick') {
|
|
39
|
+
b.add('move', () =>
|
|
40
|
+
new TouchStick({
|
|
41
|
+
id: 'move',
|
|
42
|
+
label: 'Move',
|
|
43
|
+
mode: 'floating',
|
|
44
|
+
placement: b.placement('move', { left: '0', bottom: '0', width: '45%', height: '75%' }),
|
|
45
|
+
}).bindDirection(controller.directionsInput),
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
b.add('move', () =>
|
|
49
|
+
new TouchDPad({
|
|
50
|
+
id: 'move',
|
|
51
|
+
label: 'Move',
|
|
52
|
+
placement: b.placement('move', { left: 4, bottom: 4 }),
|
|
53
|
+
}).bindDirection(controller.directionsInput),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const stick = look === 'stick';
|
|
57
|
+
if (stick) {
|
|
58
|
+
b.add('look-stick', () =>
|
|
59
|
+
new TouchStick({
|
|
60
|
+
id: 'look-stick',
|
|
61
|
+
label: 'Look',
|
|
62
|
+
placement: b.placement('look-stick', { right: 4, bottom: 3 }),
|
|
63
|
+
}).bindLook(controller.mouseInput, 900 * (options.lookSensitivity ?? 1)),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
// the keys `FreeCameraController` moves up/down and boosts with are fixed
|
|
67
|
+
b.add('ascend', () =>
|
|
68
|
+
new TouchButton({
|
|
69
|
+
id: 'ascend',
|
|
70
|
+
label: 'Up',
|
|
71
|
+
content: b.icon('ascend', MobileControlsIcons.up),
|
|
72
|
+
placement: b.placement(
|
|
73
|
+
'ascend',
|
|
74
|
+
stick ? { right: 4, bottom: 20, width: 8, height: 8 } : { right: 4, bottom: 15, width: 9, height: 9 },
|
|
75
|
+
),
|
|
76
|
+
}).bindKey(controller.keyboard, 'KeyE'),
|
|
77
|
+
);
|
|
78
|
+
b.add('descend', () =>
|
|
79
|
+
new TouchButton({
|
|
80
|
+
id: 'descend',
|
|
81
|
+
label: 'Down',
|
|
82
|
+
content: b.icon('descend', MobileControlsIcons.down),
|
|
83
|
+
placement: b.placement(
|
|
84
|
+
'descend',
|
|
85
|
+
stick ? { right: 14, bottom: 20, width: 8, height: 8 } : { right: 4, bottom: 4, width: 9, height: 9 },
|
|
86
|
+
),
|
|
87
|
+
}).bindKey(controller.keyboard, 'KeyQ'),
|
|
88
|
+
);
|
|
89
|
+
b.add('boost', () =>
|
|
90
|
+
new TouchButton({
|
|
91
|
+
id: 'boost',
|
|
92
|
+
label: 'Boost',
|
|
93
|
+
mode: 'toggle',
|
|
94
|
+
content: b.icon('boost', MobileControlsIcons.run),
|
|
95
|
+
placement: b.placement(
|
|
96
|
+
'boost',
|
|
97
|
+
stick ? { right: 24, bottom: 20, width: 8, height: 8 } : { right: 15, bottom: 4, width: 8, height: 8 },
|
|
98
|
+
),
|
|
99
|
+
}).bindKey(controller.keyboard, 'ShiftLeft'),
|
|
100
|
+
);
|
|
101
|
+
return b.finish(controller, context);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { GgWorld, IEntity } from '@gg-web-engine/core';
|
|
2
|
+
import { ControlPlacement, TouchControl } from './controls/touch-control';
|
|
3
|
+
import type { MobileControls } from './mobile-controls.entity';
|
|
4
|
+
|
|
5
|
+
export type MobileControlsLayoutContext = {
|
|
6
|
+
world: GgWorld<any, any>;
|
|
7
|
+
/** The overlay the layout is built for. */
|
|
8
|
+
controls: MobileControls;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One piece of a layout: a `TouchControl`, or anything else that lives as long as the layout does (a
|
|
13
|
+
* `TiltInput` wired to the controller, a subscription) and is ended through `dispose`.
|
|
14
|
+
*/
|
|
15
|
+
export type MobileControlsLayoutItem = TouchControl | { dispose(): void };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds the on-screen controls for one controller entity, already bound to it. Called every time
|
|
19
|
+
* the controller becomes active; the returned items are disposed when it stops being active or
|
|
20
|
+
* leaves the world. The controls are stacked in the returned order, the last one on top.
|
|
21
|
+
*/
|
|
22
|
+
export type MobileControlsLayoutFactory<T extends IEntity = IEntity> = (
|
|
23
|
+
controller: T,
|
|
24
|
+
context: MobileControlsLayoutContext,
|
|
25
|
+
) => MobileControlsLayoutItem[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What every built-in layout lets an app adjust without replacing the layout. `Id` are the ids of
|
|
29
|
+
* the controls that layout can contain.
|
|
30
|
+
*/
|
|
31
|
+
export type LayoutCustomization<T extends IEntity, Id extends string> = {
|
|
32
|
+
/** Moves/resizes a control. Merged over that control's default placement, edge by edge. */
|
|
33
|
+
placements?: { [id in Id]?: ControlPlacement };
|
|
34
|
+
/** Replaces what a button shows: markup (an inline SVG, plain text) or a DOM node. */
|
|
35
|
+
icons?: { [id in Id]?: string | Node };
|
|
36
|
+
/** Leaves controls out of the layout. */
|
|
37
|
+
hide?: Id[];
|
|
38
|
+
/** Adds controls of the app's own to the layout. */
|
|
39
|
+
extra?: MobileControlsLayoutFactory<T>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Applies a `LayoutCustomization` while a built-in layout is put together.
|
|
44
|
+
*/
|
|
45
|
+
export class LayoutBuilder<T extends IEntity, Id extends string> {
|
|
46
|
+
public readonly result: MobileControlsLayoutItem[] = [];
|
|
47
|
+
|
|
48
|
+
constructor(private readonly customization: LayoutCustomization<T, Id>) {}
|
|
49
|
+
|
|
50
|
+
/** Whether the control with this id is to be built at all. */
|
|
51
|
+
has(id: Id): boolean {
|
|
52
|
+
return !(this.customization.hide || []).includes(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
placement(id: Id, defaults: ControlPlacement): ControlPlacement {
|
|
56
|
+
const custom = this.customization.placements?.[id];
|
|
57
|
+
if (!custom) {
|
|
58
|
+
return defaults;
|
|
59
|
+
}
|
|
60
|
+
// an edge given by the app takes over from the opposite default one instead of stretching between
|
|
61
|
+
const merged = { ...defaults, ...custom };
|
|
62
|
+
if (custom.left !== undefined && custom.right === undefined) delete merged.right;
|
|
63
|
+
if (custom.right !== undefined && custom.left === undefined) delete merged.left;
|
|
64
|
+
if (custom.top !== undefined && custom.bottom === undefined) delete merged.bottom;
|
|
65
|
+
if (custom.bottom !== undefined && custom.top === undefined) delete merged.top;
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
icon(id: Id, defaults: string): string | Node {
|
|
70
|
+
return this.customization.icons?.[id] ?? defaults;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Adds the control `create` makes, unless the app hid it. */
|
|
74
|
+
add(id: Id, create: () => TouchControl): void {
|
|
75
|
+
if (this.has(id)) {
|
|
76
|
+
this.result.push(create());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
finish(controller: T, context: MobileControlsLayoutContext): MobileControlsLayoutItem[] {
|
|
81
|
+
if (this.customization.extra) {
|
|
82
|
+
this.result.push(...this.customization.extra(controller, context));
|
|
83
|
+
}
|
|
84
|
+
return this.result;
|
|
85
|
+
}
|
|
86
|
+
}
|