-
Notifications
You must be signed in to change notification settings - Fork 62
Expand file tree
/
Copy pathInputSystem.ts
More file actions
347 lines (284 loc) · 13.9 KB
/
Copy pathInputSystem.ts
File metadata and controls
347 lines (284 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
import type { KeyCode } from "@/systems/input/KeyboardTypes.ts"
import { globalAddToast } from "@/ui/components/GlobalUIControls"
import { TouchControlsAxes } from "@/ui/components/TouchControls"
import World from "../World"
import WorldSystem from "../WorldSystem"
import type { InputName, InputScheme, ModifierState } from "./InputTypes"
import type Input from "./inputs/Input"
const LOG_GAMEPAD_EVENTS = false
/**
* The input system listens for and records key presses and joystick positions to be used by robots.
* It also maps robot behaviors (such as an arcade drivetrain or an arm) to specific keys through customizable input schemes.
*/
class InputSystem extends WorldSystem {
public static currentModifierState: ModifierState
/** The keys currently being pressed. */
private static _keysPressed: Partial<Record<KeyCode, boolean>> = {}
/** Whether the command palette is currently open, which blocks robot input */
private static _isCommandPaletteOpen: boolean = false
private static _gpIndexes: number[] = []
public static gamepads: (Gamepad | null)[] = []
/** Normalized joystick positions (-1 to 1) set by TouchControls component via react-joystick-component */
private static _leftJoystickPos: { x: number; y: number } = { x: 0, y: 0 }
private static _rightJoystickPos: { x: number; y: number } = { x: 0, y: 0 }
/** Maps a brain index to an input scheme. */
private static _brainIndexSchemeMap: Map<number, InputScheme> = new Map()
public static get brainIndexSchemeMap() {
return this._brainIndexSchemeMap
}
/**
* Maps a brain index to the logical controller slot (0 = first connected gamepad) that drives it.
* Controller assignment is per-robot, so this is intentionally separate from the (shared) input scheme.
*/
public static brainIndexPlayerSlotMap: Map<number, number> = new Map()
public static setBrainIndexSchemeMapping(index: number, scheme: InputScheme) {
this.brainIndexSchemeMap.set(index, scheme)
World.analyticsSystem?.event("Scheme Applied", {
isCustomized: scheme.customized,
schemeName: scheme.schemeName,
})
InputSystem.warnIfControllerShared(index)
}
/** @returns the controller slot assigned to the given brain, defaulting to slot 0. */
public static getPlayerSlot(brainIndex: number): number {
return InputSystem.brainIndexPlayerSlotMap.get(brainIndex) ?? 0
}
/** Assigns a controller slot to a brain and warns if that controller now drives multiple robots. */
public static setPlayerSlot(brainIndex: number, slot: number) {
InputSystem.brainIndexPlayerSlotMap.set(brainIndex, slot)
InputSystem.warnIfControllerShared(brainIndex)
}
/**
* Warns the user when a brain's gamepad scheme results in a single physical controller
* driving more than one robot. Gamepad schemes are intentionally shareable, so this is an
* informational heads-up rather than a block.
*/
private static warnIfControllerShared(brainIndex: number) {
const scheme = InputSystem.brainIndexSchemeMap.get(brainIndex)
if (scheme == null || !scheme.usesGamepad) return
const slot = InputSystem.getPlayerSlot(brainIndex)
let robotsOnSlot = 0
for (const [boundIndex, boundScheme] of InputSystem.brainIndexSchemeMap) {
if (boundScheme.usesGamepad && InputSystem.getPlayerSlot(boundIndex) === slot) robotsOnSlot++
}
if (robotsOnSlot >= 2) {
globalAddToast("warning", `Controller ${slot + 1} is now controlling ${robotsOnSlot} robots.`)
}
}
public static getBrainIndexSchemeMapping(index: number): InputScheme | undefined {
return this.brainIndexSchemeMap.get(index)
}
// Janky solution to centralize escape key closing logic, first in the list is higher priority, returning true consumes the keypress
public static escapeKeyListeners: (null | (() => boolean))[] = [null, null, null]
/**
* Sets whether the command palette is open, which blocks all robot inputs
*/
public static setCommandPaletteOpen(isOpen: boolean) {
InputSystem._isCommandPaletteOpen = isOpen
}
/** Called by TouchControls component to update the left joystick position. Values are normalized (-1 to 1) */
public static setLeftJoystick(x: number, y: number) {
InputSystem._leftJoystickPos = { x, y }
}
/** Called by TouchControls component to update the right joystick position. Values are normalized (-1 to 1) */
public static setRightJoystick(x: number, y: number) {
InputSystem._rightJoystickPos = { x, y }
}
constructor() {
super()
// Initialize input events
this.handleKeyDown = this.handleKeyDown.bind(this)
document.addEventListener("keydown", this.handleKeyDown)
this.handleKeyUp = this.handleKeyUp.bind(this)
document.addEventListener("keyup", this.handleKeyUp)
this.gamepadConnected = this.gamepadConnected.bind(this)
window.addEventListener("gamepadconnected", this.gamepadConnected)
this.gamepadDisconnected = this.gamepadDisconnected.bind(this)
window.addEventListener("gamepaddisconnected", this.gamepadDisconnected)
// Initialize an event that's triggered when the user exits/enters the page
document.addEventListener("visibilitychange", () => {
if (document.hidden) this.clearKeyData()
})
// Disable gesture inputs on track pad to zoom into UI
window.addEventListener(
"wheel",
function (e) {
if (e.ctrlKey) {
e.preventDefault() // Prevent the zoom
}
},
{ passive: false }
)
}
public update(_: number): void {
// Fetch current gamepad information
const rawGamepads = navigator.getGamepads()
for (const lookupIndex of InputSystem._gpIndexes) {
if (lookupIndex == null || rawGamepads[lookupIndex] == null) {
InputSystem.gamepads[lookupIndex] = null
} else {
InputSystem.gamepads[lookupIndex] = rawGamepads[lookupIndex]
}
}
if (!document.hasFocus()) this.clearKeyData()
// Update the current modifier state to be checked against target stats when getting input values
InputSystem.currentModifierState = {
ctrl: InputSystem.isKeyPressed("ControlLeft") || InputSystem.isKeyPressed("ControlRight"),
alt: InputSystem.isKeyPressed("AltLeft") || InputSystem.isKeyPressed("AltRight"),
shift: InputSystem.isKeyPressed("ShiftLeft") || InputSystem.isKeyPressed("ShiftRight"),
meta: InputSystem.isKeyPressed("MetaLeft") || InputSystem.isKeyPressed("MetaRight"),
}
}
public destroy(): void {
document.removeEventListener("keydown", this.handleKeyDown)
document.removeEventListener("keyup", this.handleKeyUp)
window.removeEventListener("gamepadconnected", this.gamepadConnected)
window.removeEventListener("gamepaddisconnected", this.gamepadDisconnected)
}
/** Called when any key is first pressed */
private handleKeyDown(event: KeyboardEvent) {
InputSystem._keysPressed[event.code as KeyCode] = true
this.checkEscapeKey(event)
}
/* Called when any key is released */
private handleKeyUp(event: KeyboardEvent) {
if (InputSystem._keysPressed["Escape"] == false) {
// Sometimes focus issues prevent the keydown from being fired, but the keyup is still sent.
this.checkEscapeKey(event)
}
InputSystem._keysPressed[event.code as KeyCode] = false
}
private checkEscapeKey(event: KeyboardEvent) {
if (event.key == "Escape") {
const anyMatched = InputSystem.escapeKeyListeners.some(cb => cb != null && cb())
if (anyMatched) {
event.preventDefault()
}
}
}
/** Clears all stored key data when the user leaves the page. */
private clearKeyData() {
for (const keyCode in InputSystem._keysPressed) delete InputSystem._keysPressed[keyCode as KeyCode]
}
/* Called once when a gamepad is first connected */
private gamepadConnected(event: GamepadEvent) {
if (LOG_GAMEPAD_EVENTS) {
console.log(
"Gamepad connected at index %d: %s. %d buttons, %d axes.",
event.gamepad.index,
event.gamepad.id,
event.gamepad.buttons.length,
event.gamepad.axes.length
)
}
const newIndex = event.gamepad.index
if (!InputSystem._gpIndexes.includes(newIndex)) {
InputSystem._gpIndexes.push(newIndex)
}
}
/* Called once when a gamepad is first disconnected */
private gamepadDisconnected(event: GamepadEvent) {
if (LOG_GAMEPAD_EVENTS) {
console.log("Gamepad disconnected from index %d: %s", event.gamepad.index, event.gamepad.id)
}
const removedIndex = event.gamepad.index
InputSystem._gpIndexes = InputSystem._gpIndexes.filter(idx => idx !== removedIndex)
if (InputSystem.gamepads[removedIndex]) {
InputSystem.gamepads[removedIndex] = null
}
}
/**
* @param {string} key - The keycode of the target key.
* @param {ModifierState} modifiers - The target modifier state. Assumed to be no modifiers if undefined.
* @returns {boolean} True if the key is pressed or false otherwise.
*/
public static isKeyPressed(key: KeyCode, modifiers?: ModifierState): boolean {
if (modifiers != null && !InputSystem.compareModifiers(InputSystem.currentModifierState, modifiers))
return false
return Boolean(InputSystem._keysPressed[key])
}
/**
* @param {string} inputName The name of the function of the input.
* @param {number} brainIndex The robot brain index for this input. Used to map to a control scheme.
* @returns {number} A number between -1 and 1 based on the current state of the input.
*/
public static getInput(inputName: InputName, brainIndex: number): number {
// Block all robot inputs when command palette is open
if (InputSystem._isCommandPaletteOpen) {
return 0
}
const targetScheme = InputSystem.getBrainIndexSchemeMapping(brainIndex)
const targetInput = targetScheme?.inputs.find(input => input.inputName == inputName) as Input
if (targetScheme == null || targetInput == null) return 0
return targetInput.getValue(
targetScheme.usesGamepad,
targetScheme.usesTouchControls,
InputSystem.getPlayerSlot(brainIndex)
)
}
/**
* @param {ModifierState} state1 Any key modifier state.
* @param {ModifierState} state2 Any key modifier state.
* @returns {boolean} True if the modifier states are identical and false otherwise.
*/
public static compareModifiers(state1: ModifierState, state2: ModifierState): boolean {
if (!state1 || !state2) return false
return (
state1.alt == state2.alt &&
state1.ctrl == state2.ctrl &&
state1.meta == state2.meta &&
state1.shift == state2.shift
)
}
/**
* @param {number} playerSlot The logical player slot.
* @returns {Gamepad | null} The gamepad in that slot, or null if the slot is unoccupied.
*/
public static getGamepadBySlot(playerSlot: number): Gamepad | null {
const rawIndex = InputSystem._gpIndexes[playerSlot]
if (rawIndex == null) return null
return InputSystem.gamepads[rawIndex] ?? null
}
/**
* @param {number} axisNumber The joystick axis index. Must be an integer.
* @param {number} playerSlot The logical player slot for the gamepad (0 = first connected). Must be an integer.
* @returns {number} A number between -1 and 1 based on the position of this axis or 0 if no gamepad is connected or the axis is not found.
*/
public static getGamepadAxis(axisNumber: number, playerSlot: number = 0): number {
const targetGamepad = InputSystem.getGamepadBySlot(playerSlot)
if (targetGamepad == null) return 0
if (axisNumber < 0 || axisNumber >= targetGamepad.axes.length) return 0
const value = targetGamepad.axes[axisNumber]
// Return value with a deadband
return Math.abs(value) < 0.15 ? 0 : value
}
/**
*
* @param {number} buttonNumber - The gamepad button index. Must be an integer.
* @param {number} playerSlot - The logical player slot for the gamepad (0 = first connected). Must be an integer.
* @returns {boolean} True if the button is pressed, false if not, a gamepad isn't connected, or the button can't be found.
*/
public static isGamepadButtonPressed(buttonNumber: number, playerSlot: number = 0): boolean {
const targetGamepad = InputSystem.getGamepadBySlot(playerSlot)
if (targetGamepad == null) return false
if (buttonNumber < 0 || buttonNumber >= targetGamepad.buttons.length) return false
const button = targetGamepad.buttons[buttonNumber]
if (button == null) return false
return button.pressed
}
/**
* @returns {number} The number of currently connected gamepads, effectively all useable slots
*/
public static getConnectedPlayerCount(): number {
return InputSystem._gpIndexes.length
}
/** Returns a number between -1 and 1 from the touch controls */
public static getTouchControlsAxis(axisType: TouchControlsAxes): number {
if (axisType === TouchControlsAxes.LEFT_X) return InputSystem._leftJoystickPos.x
if (axisType === TouchControlsAxes.LEFT_Y) return InputSystem._leftJoystickPos.y
if (axisType === TouchControlsAxes.RIGHT_X) return InputSystem._rightJoystickPos.x
if (axisType === TouchControlsAxes.RIGHT_Y) return InputSystem._rightJoystickPos.y
return 0
}
}
export default InputSystem