canvasengine 2.0.0-beta.33 → 2.0.0-beta.35

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.
@@ -0,0 +1,515 @@
1
+ import { ControlsBase, Controls } from "./ControlsBase";
2
+ import { WritableSignal } from "@signe/reactive";
3
+ import 'joypad.js'
4
+
5
+ /**
6
+ * Gamepad configuration interface
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const gamepadConfig: GamepadConfig = {
11
+ * enabled: true,
12
+ * buttonMapping: {
13
+ * 'button_0': 'action',
14
+ * 'button_1': 'back'
15
+ * },
16
+ * axisMapping: {
17
+ * 'top': 'up',
18
+ * 'bottom': 'down',
19
+ * 'left': 'left',
20
+ * 'right': 'right'
21
+ * },
22
+ * moveInterval: 400,
23
+ * onConnect: () => console.log('Gamepad connected!'),
24
+ * onDisconnect: () => console.log('Gamepad disconnected!')
25
+ * };
26
+ * ```
27
+ */
28
+ export interface GamepadConfig {
29
+ /** Whether gamepad is enabled (default: true) */
30
+ enabled?: boolean;
31
+ /** Mapping of gamepad button names to control names */
32
+ buttonMapping?: {
33
+ [buttonName: string]: string; // e.g., 'button_0' -> 'action'
34
+ };
35
+ /** Mapping of axis directions to control directions */
36
+ axisMapping?: {
37
+ [axisDirection: string]: string; // e.g., 'top' -> 'up'
38
+ };
39
+ /** Threshold for axis movement detection (default: 0.5) */
40
+ axisThreshold?: number;
41
+ /** Interval in milliseconds for repeating movement actions (default: 400) */
42
+ moveInterval?: number;
43
+ /** Callback called when a gamepad is connected */
44
+ onConnect?: () => void;
45
+ /** Callback called when a gamepad is disconnected */
46
+ onDisconnect?: () => void;
47
+ /** Signal that tracks gamepad connection status (optional) */
48
+ gamepadConnected?: WritableSignal<boolean>;
49
+ }
50
+
51
+ /**
52
+ * Default button mapping
53
+ */
54
+ const DEFAULT_BUTTON_MAPPING: { [buttonName: string]: string } = {
55
+ 'button_0': 'action',
56
+ 'button_1': 'back',
57
+ 'button_9': 'back'
58
+ };
59
+
60
+ /**
61
+ * Default axis mapping
62
+ */
63
+ const DEFAULT_AXIS_MAPPING: { [axisDirection: string]: string } = {
64
+ 'top': 'up',
65
+ 'bottom': 'down',
66
+ 'left': 'left',
67
+ 'right': 'right'
68
+ };
69
+
70
+ /**
71
+ * Gamepad input controls implementation
72
+ *
73
+ * Handles gamepad input events using joypad.js library and maps them to control actions.
74
+ * Supports button presses and analog stick movement with configurable mappings.
75
+ *
76
+ * The gamepad controls are automatically activated when joypad.js is available and enabled.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * const gamepadControls = new GamepadControls();
81
+ * gamepadControls.setInputs({
82
+ * up: {
83
+ * repeat: true,
84
+ * bind: 'up',
85
+ * keyDown() {
86
+ * console.log('Up pressed');
87
+ * }
88
+ * }
89
+ * });
90
+ * gamepadControls.updateGamepadConfig({
91
+ * enabled: true,
92
+ * buttonMapping: {
93
+ * 'button_0': 'action'
94
+ * }
95
+ * });
96
+ * gamepadControls.start();
97
+ * ```
98
+ */
99
+ export class GamepadControls extends ControlsBase {
100
+ private gamepadEnabled: boolean = true;
101
+ private gamepadConfig: GamepadConfig = {
102
+ enabled: true,
103
+ buttonMapping: DEFAULT_BUTTON_MAPPING,
104
+ axisMapping: DEFAULT_AXIS_MAPPING,
105
+ axisThreshold: 0.5,
106
+ moveInterval: 400
107
+ };
108
+ private gamepadMoving: boolean = false;
109
+ private gamepadDirections: { [direction: string]: boolean } = {};
110
+ private gamepadAxisDate: number = 0;
111
+ private gamepadMoveInterval: any = null;
112
+ private joypad: any = null;
113
+ private connectCallbacks: Array<() => void> = [];
114
+ private disconnectCallbacks: Array<() => void> = [];
115
+
116
+ /**
117
+ * Setup gamepad event listeners
118
+ * Initializes joypad.js if available
119
+ */
120
+ protected setupListeners(): void {
121
+ this.initGamepad();
122
+ }
123
+
124
+ /**
125
+ * Cleanup gamepad event listeners and intervals
126
+ */
127
+ protected cleanup(): void {
128
+ if (this.gamepadMoveInterval) {
129
+ clearInterval(this.gamepadMoveInterval);
130
+ this.gamepadMoveInterval = null;
131
+ }
132
+
133
+ if (this.joypad) {
134
+ this.joypad.off('connect');
135
+ this.joypad.off('disconnect');
136
+ this.joypad.off('button_press');
137
+ this.joypad.off('axis_move');
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Initialize joypad.js library if available
143
+ */
144
+ private initGamepad(): void {
145
+ if (typeof window === 'undefined') return;
146
+
147
+ const joypadLib = (window as any)['joypad'];
148
+
149
+ if (!joypadLib) {
150
+ // joypad.js not available
151
+ // Set initial state if signal is provided
152
+ if (this.gamepadConfig.gamepadConnected) {
153
+ this.gamepadConfig.gamepadConnected.set(false);
154
+ }
155
+ return;
156
+ }
157
+
158
+ this.joypad = joypadLib;
159
+
160
+ // Setup event listeners
161
+ this.joypad.on('connect', () => this.handleGamepadConnect());
162
+ this.joypad.on('disconnect', () => this.handleGamepadDisconnect());
163
+ this.joypad.on('button_press', (e: any) => this.handleGamepadButtonPress(e));
164
+ this.joypad.on('axis_move', (e: any) => this.handleGamepadAxisMove(e));
165
+ }
166
+
167
+ /**
168
+ * Handle gamepad connection event
169
+ */
170
+ private handleGamepadConnect(): void {
171
+ if (!this.gamepadEnabled || !this.gamepadConfig.enabled) return;
172
+
173
+ // Start movement processing interval
174
+ this.gamepadMoveInterval = setInterval(() => {
175
+ this.processGamepadMovement();
176
+ }, this.gamepadConfig.moveInterval || 400);
177
+
178
+ // Update gamepadConnected signal if provided
179
+ if (this.gamepadConfig.gamepadConnected) {
180
+ this.gamepadConfig.gamepadConnected.set(true);
181
+ }
182
+
183
+ // Call all registered connect callbacks
184
+ this.connectCallbacks.forEach(callback => callback());
185
+ }
186
+
187
+ /**
188
+ * Handle gamepad disconnection event
189
+ */
190
+ private handleGamepadDisconnect(): void {
191
+ // Stop movement interval
192
+ if (this.gamepadMoveInterval) {
193
+ clearInterval(this.gamepadMoveInterval);
194
+ this.gamepadMoveInterval = null;
195
+ }
196
+
197
+ // Reset states
198
+ this.gamepadMoving = false;
199
+ this.gamepadDirections = {};
200
+ this.gamepadAxisDate = 0;
201
+
202
+ // Update gamepadConnected signal if provided
203
+ if (this.gamepadConfig.gamepadConnected) {
204
+ this.gamepadConfig.gamepadConnected.set(false);
205
+ }
206
+
207
+ // Call all registered disconnect callbacks
208
+ this.disconnectCallbacks.forEach(callback => callback());
209
+ }
210
+
211
+ /**
212
+ * Handle gamepad button press event
213
+ *
214
+ * @param e - Button press event from joypad.js
215
+ */
216
+ private handleGamepadButtonPress(e: any): void {
217
+ if (!this.gamepadEnabled || !this.gamepadConfig.enabled) return;
218
+ if (this.stop) return;
219
+
220
+ const { buttonName } = e.detail;
221
+ const buttonMapping = this.gamepadConfig.buttonMapping || DEFAULT_BUTTON_MAPPING;
222
+ const controlName = buttonMapping[buttonName];
223
+
224
+ if (controlName) {
225
+ // Apply the control action (press and release)
226
+ this.applyControl(controlName).catch(() => {
227
+ // Ignore errors
228
+ });
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Handle gamepad axis movement event
234
+ *
235
+ * @param e - Axis move event from joypad.js
236
+ */
237
+ private handleGamepadAxisMove(e: any): void {
238
+ if (!this.gamepadEnabled || !this.gamepadConfig.enabled) return;
239
+ if (this.stop) return;
240
+
241
+ this.gamepadMoving = true;
242
+ this.gamepadAxisDate = Date.now();
243
+
244
+ let direction = e.detail.directionOfMovement;
245
+ const axisMapping = this.gamepadConfig.axisMapping || DEFAULT_AXIS_MAPPING;
246
+
247
+ // Map joypad direction to control direction
248
+ if (direction === 'top') direction = axisMapping['top'] || 'up';
249
+ else if (direction === 'bottom') direction = axisMapping['bottom'] || 'down';
250
+ else if (direction === 'left') direction = axisMapping['left'] || 'left';
251
+ else if (direction === 'right') direction = axisMapping['right'] || 'right';
252
+
253
+ // Update active directions
254
+ this.gamepadDirections = {
255
+ [direction]: true
256
+ };
257
+
258
+ // Release other directions
259
+ const allDirections = ['up', 'down', 'left', 'right'];
260
+ for (const dir of allDirections) {
261
+ if (!this.gamepadDirections[dir]) {
262
+ this.applyControl(dir, false).catch(() => {
263
+ // Ignore errors
264
+ });
265
+ }
266
+ }
267
+
268
+ // Trigger movement
269
+ this.processGamepadMovement();
270
+ }
271
+
272
+ /**
273
+ * Process continuous gamepad movement
274
+ * Called at intervals to repeat movement actions while axes are active
275
+ */
276
+ private processGamepadMovement(): void {
277
+ if (!this.gamepadMoving) return;
278
+ if (this.stop) return;
279
+
280
+ for (const direction in this.gamepadDirections) {
281
+ if (this.gamepadDirections[direction]) {
282
+ this.applyControl(direction, true).catch(() => {
283
+ // Ignore errors
284
+ });
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Process gamepad inputs each step
291
+ * Handles timeout for stopping movements after axis inactivity
292
+ */
293
+ protected preStep(): void {
294
+ if (this.stop) return;
295
+
296
+ // Stop movements if no axis input for 100ms
297
+ const now = Date.now();
298
+ if (now - this.gamepadAxisDate > 100 && this.gamepadMoving) {
299
+ const allDirections = ['up', 'down', 'left', 'right'];
300
+ for (const dir of allDirections) {
301
+ this.gamepadDirections = {};
302
+ this.gamepadMoving = false;
303
+ this.applyControl(dir, false).catch(() => {
304
+ // Ignore errors
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Update gamepad configuration
312
+ * Merges provided config with defaults
313
+ * Automatically registers callbacks from config
314
+ *
315
+ * @param config - Partial gamepad configuration
316
+ */
317
+ updateGamepadConfig(config: Partial<GamepadConfig>): void {
318
+ // Remove old callbacks if they were registered
319
+ if (this.gamepadConfig.onConnect) {
320
+ this.offConnect(this.gamepadConfig.onConnect);
321
+ }
322
+ if (this.gamepadConfig.onDisconnect) {
323
+ this.offDisconnect(this.gamepadConfig.onDisconnect);
324
+ }
325
+
326
+ this.gamepadConfig = {
327
+ enabled: config.enabled !== undefined ? config.enabled : true,
328
+ buttonMapping: config.buttonMapping || DEFAULT_BUTTON_MAPPING,
329
+ axisMapping: config.axisMapping || DEFAULT_AXIS_MAPPING,
330
+ axisThreshold: config.axisThreshold || 0.5,
331
+ moveInterval: config.moveInterval || 400,
332
+ onConnect: config.onConnect,
333
+ onDisconnect: config.onDisconnect,
334
+ gamepadConnected: config.gamepadConnected
335
+ };
336
+
337
+ // Register new callbacks if provided
338
+ if (this.gamepadConfig.onConnect) {
339
+ this.onConnect(this.gamepadConfig.onConnect);
340
+ }
341
+ if (this.gamepadConfig.onDisconnect) {
342
+ this.onDisconnect(this.gamepadConfig.onDisconnect);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Extract gamepad config from controls configuration and update
348
+ * Note: Callbacks are stored but not automatically registered, they should be registered in mount()
349
+ *
350
+ * @param inputs - Controls configuration that may contain a 'gamepad' property
351
+ */
352
+ extractGamepadConfig(inputs: Controls & { gamepad?: GamepadConfig }): void {
353
+ if (inputs.gamepad) {
354
+ this.updateGamepadConfig(inputs.gamepad);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Get the current gamepad configuration
360
+ *
361
+ * @returns The gamepad configuration object
362
+ */
363
+ getGamepadConfig(): GamepadConfig {
364
+ return this.gamepadConfig;
365
+ }
366
+
367
+ /**
368
+ * Apply a control action programmatically
369
+ * Uses the bound controls to trigger actions
370
+ *
371
+ * @param controlName - Name of the control
372
+ * @param isDown - Whether the control is pressed (true) or released (false)
373
+ * @returns Promise that resolves when the action is complete
374
+ */
375
+ async applyControl(controlName: string | number, isDown?: boolean): Promise<void> {
376
+ const control = this._controlsOptions[controlName];
377
+ if (!control) return;
378
+
379
+ // Find the bound key for this control
380
+ const boundKeys = Object.keys(this.boundKeys);
381
+ for (const keyName of boundKeys) {
382
+ const boundKey = this.boundKeys[keyName];
383
+ if (boundKey.actionName === String(controlName)) {
384
+ // Execute the control callback
385
+ if (isDown === undefined) {
386
+ // Press and release (simulate button press)
387
+ if (boundKey.options.keyDown) {
388
+ let parameters = boundKey.parameters;
389
+ if (typeof parameters === "function") {
390
+ parameters = parameters();
391
+ }
392
+ boundKey.options.keyDown(boundKey);
393
+ }
394
+ // Release after a short delay (similar to keyboard)
395
+ return new Promise((resolve) => {
396
+ setTimeout(() => {
397
+ if (boundKey.options.keyUp) {
398
+ let parameters = boundKey.parameters;
399
+ if (typeof parameters === "function") {
400
+ parameters = parameters();
401
+ }
402
+ boundKey.options.keyUp(boundKey);
403
+ }
404
+ resolve();
405
+ }, 200);
406
+ });
407
+ } else if (isDown) {
408
+ if (boundKey.options.keyDown) {
409
+ let parameters = boundKey.parameters;
410
+ if (typeof parameters === "function") {
411
+ parameters = parameters();
412
+ }
413
+ boundKey.options.keyDown(boundKey);
414
+ }
415
+ } else {
416
+ if (boundKey.options.keyUp) {
417
+ let parameters = boundKey.parameters;
418
+ if (typeof parameters === "function") {
419
+ parameters = parameters();
420
+ }
421
+ boundKey.options.keyUp(boundKey);
422
+ }
423
+ }
424
+ break;
425
+ }
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Override setInputs to extract gamepad config
431
+ */
432
+ setInputs(inputs: Controls & { gamepad?: GamepadConfig }): void {
433
+ super.setInputs(inputs);
434
+ this.extractGamepadConfig(inputs);
435
+ }
436
+
437
+ /**
438
+ * Register a callback to be called when a gamepad is connected
439
+ *
440
+ * @param callback - Function to call when gamepad connects
441
+ * @example
442
+ * ```ts
443
+ * gamepadControls.onConnect(() => {
444
+ * console.log('Gamepad connected!');
445
+ * });
446
+ * ```
447
+ */
448
+ onConnect(callback: () => void): void {
449
+ this.connectCallbacks.push(callback);
450
+ }
451
+
452
+ /**
453
+ * Register a callback to be called when a gamepad is disconnected
454
+ *
455
+ * @param callback - Function to call when gamepad disconnects
456
+ * @example
457
+ * ```ts
458
+ * gamepadControls.onDisconnect(() => {
459
+ * console.log('Gamepad disconnected!');
460
+ * });
461
+ * ```
462
+ */
463
+ onDisconnect(callback: () => void): void {
464
+ this.disconnectCallbacks.push(callback);
465
+ }
466
+
467
+ /**
468
+ * Remove a connect callback
469
+ *
470
+ * @param callback - Callback to remove
471
+ */
472
+ offConnect(callback: () => void): void {
473
+ const index = this.connectCallbacks.indexOf(callback);
474
+ if (index > -1) {
475
+ this.connectCallbacks.splice(index, 1);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Remove a disconnect callback
481
+ *
482
+ * @param callback - Callback to remove
483
+ */
484
+ offDisconnect(callback: () => void): void {
485
+ const index = this.disconnectCallbacks.indexOf(callback);
486
+ if (index > -1) {
487
+ this.disconnectCallbacks.splice(index, 1);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Check if gamepad is currently connected
493
+ *
494
+ * @returns true if gamepad is connected, false otherwise
495
+ */
496
+ isConnected(): boolean {
497
+ return this.gamepadMoveInterval !== null;
498
+ }
499
+
500
+ /**
501
+ * Reinitialize gamepad listeners
502
+ * Useful if joypad.js becomes available after initialization
503
+ *
504
+ * @example
505
+ * ```ts
506
+ * // If joypad.js loads later
507
+ * gamepadControls.reinit();
508
+ * ```
509
+ */
510
+ reinit(): void {
511
+ if (!this.joypad) {
512
+ this.initGamepad();
513
+ }
514
+ }
515
+ }