diff --git a/index.html b/index.html index fea89f1..85b3fa1 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@ @@ -308,6 +309,139 @@ + +
+
+ ← Back to Game + +

šŸŽ® Controller Mapping

+

Customize VR controller button and stick mappings

+ +
+ +
+

šŸ•¹ļø Left Stick

+

+ Configure what actions the left thumbstick controls. +

+ +
+ + + +
+ +
+ + + +
+
+ + +
+

šŸ•¹ļø Right Stick

+

+ Configure what actions the right thumbstick controls. +

+ +
+ + + +
+ +
+ + + +
+
+ + +
+

šŸ”˜ Button Mappings

+

+ Configure what actions each controller button performs. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

ā„¹ļø Action Guide

+
+

Yaw: Turn left/right (rotate around vertical axis)

+

Pitch: Nose up/down (rotate around horizontal axis)

+

Roll: Barrel roll (rotate around forward axis)

+

Forward: Forward and backward thrust

+

None: No action assigned

+
+
+ + +
+

šŸ’¾ Storage Info

+
+

Controller mappings are automatically saved to your browser's local storage and will persist between sessions.

+

+ āš ļø Note: Changes will take effect when you start a new level. Restart the current level to see changes. +

+
+
+
+ +
+ + + +
+ +
+
+
+
diff --git a/src/main.ts b/src/main.ts index c95c69f..551c33c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -713,6 +713,18 @@ router.on('/settings', () => { } }); +router.on('/controls', () => { + showView('controls'); + // Dynamically import and initialize controls screen + if (!(window as any).__controlsInitialized) { + import('./ui/screens/controlsScreen').then((module) => { + const controlsScreen = new module.ControlsScreen(); + controlsScreen.initialize(); + (window as any).__controlsInitialized = true; + }); + } +}); + // Initialize registry and start router // This must happen BEFORE router.start() so levels are available async function initializeApp() { diff --git a/src/ship/input/controllerInput.ts b/src/ship/input/controllerInput.ts index 4762c30..decbbab 100644 --- a/src/ship/input/controllerInput.ts +++ b/src/ship/input/controllerInput.ts @@ -6,6 +6,7 @@ import { WebXRInputSource, } from "@babylonjs/core"; import debugLog from "../../core/debug"; +import { ControllerMappingConfig, StickAction } from "./controllerMapping"; const controllerComponents = [ "a-button", @@ -38,8 +39,16 @@ interface CameraAdjustment { * Maps controller thumbsticks and buttons to ship controls */ export class ControllerInput { + // Raw stick values (before mapping) + private _rawLeftStickX: number = 0; + private _rawLeftStickY: number = 0; + private _rawRightStickX: number = 0; + private _rawRightStickY: number = 0; + + // Legacy stick vectors (for compatibility) private _leftStick: Vector2 = Vector2.Zero(); private _rightStick: Vector2 = Vector2.Zero(); + private _shooting: boolean = false; private _leftInputSource: WebXRInputSource; private _rightInputSource: WebXRInputSource; @@ -50,9 +59,11 @@ export class ControllerInput { new Observable(); private _onStatusScreenToggleObservable: Observable = new Observable(); private _enabled: boolean = true; + private _mappingConfig: ControllerMappingConfig; constructor() { this._controllerObservable.add(this.handleControllerEvent.bind(this)); + this._mappingConfig = ControllerMappingConfig.getInstance(); } /** @@ -78,6 +89,7 @@ export class ControllerInput { /** * Get current input state (stick positions) + * Applies controller mapping configuration to translate raw input to actions */ public getInputState() { if (!this._enabled) { @@ -86,9 +98,41 @@ export class ControllerInput { rightStick: Vector2.Zero(), }; } + + const mapping = this._mappingConfig.getMapping(); + + // Create action values map + const actions = new Map(); + actions.set('yaw', 0); + actions.set('pitch', 0); + actions.set('roll', 0); + actions.set('forward', 0); + + // Apply raw stick values to configured actions (with inversion) + const leftX = mapping.invertLeftStickX ? -this._rawLeftStickX : this._rawLeftStickX; + const leftY = mapping.invertLeftStickY ? -this._rawLeftStickY : this._rawLeftStickY; + const rightX = mapping.invertRightStickX ? -this._rawRightStickX : this._rawRightStickX; + const rightY = mapping.invertRightStickY ? -this._rawRightStickY : this._rawRightStickY; + + if (mapping.leftStickX !== 'none') { + actions.set(mapping.leftStickX, leftX); + } + if (mapping.leftStickY !== 'none') { + actions.set(mapping.leftStickY, leftY); + } + if (mapping.rightStickX !== 'none') { + actions.set(mapping.rightStickX, rightX); + } + if (mapping.rightStickY !== 'none') { + actions.set(mapping.rightStickY, rightY); + } + + // Map actions back to virtual stick positions for ShipPhysics + // leftStick.x = yaw, leftStick.y = forward + // rightStick.x = roll, rightStick.y = pitch return { - leftStick: this._leftStick.clone(), - rightStick: this._rightStick.clone(), + leftStick: new Vector2(actions.get('yaw') || 0, actions.get('forward') || 0), + rightStick: new Vector2(actions.get('roll') || 0, actions.get('pitch') || 0), }; } @@ -98,7 +142,12 @@ export class ControllerInput { public setEnabled(enabled: boolean): void { this._enabled = enabled; if (!enabled) { - // Reset stick values when disabled + // Reset raw stick values when disabled + this._rawLeftStickX = 0; + this._rawLeftStickY = 0; + this._rawRightStickX = 0; + this._rawRightStickY = 0; + // Also reset legacy values this._leftStick.x = 0; this._leftStick.y = 0; this._rightStick.x = 0; @@ -232,14 +281,15 @@ export class ControllerInput { } if (controllerEvent.type === "thumbstick") { + // Store raw stick values (mapping will be applied in getInputState()) if (controllerEvent.hand === "left") { - this._leftStick.x = controllerEvent.axisData.x; - this._leftStick.y = controllerEvent.axisData.y; + this._rawLeftStickX = controllerEvent.axisData.x; + this._rawLeftStickY = controllerEvent.axisData.y; } if (controllerEvent.hand === "right") { - this._rightStick.x = controllerEvent.axisData.x; - this._rightStick.y = controllerEvent.axisData.y; + this._rawRightStickX = controllerEvent.axisData.x; + this._rawRightStickY = controllerEvent.axisData.y; } } diff --git a/src/ship/input/controllerMapping.ts b/src/ship/input/controllerMapping.ts new file mode 100644 index 0000000..8edbbf4 --- /dev/null +++ b/src/ship/input/controllerMapping.ts @@ -0,0 +1,268 @@ +import debugLog from '../../core/debug'; + +const STORAGE_KEY = 'space-game-controller-mapping'; + +/** + * Available stick actions + */ +export type StickAction = + | 'yaw' // Rotation around Y-axis (left/right turn) + | 'pitch' // Rotation around X-axis (nose up/down) + | 'roll' // Rotation around Z-axis (barrel roll) + | 'forward' // Forward/backward thrust + | 'none'; // No action + +/** + * Available button actions + */ +export type ButtonAction = + | 'fire' // Fire weapon + | 'cameraUp' // Adjust camera up + | 'cameraDown' // Adjust camera down + | 'statusScreen' // Toggle status screen + | 'none'; // No action + +/** + * Complete controller mapping configuration + */ +export interface ControllerMapping { + // Stick axis mappings + leftStickX: StickAction; + leftStickY: StickAction; + rightStickX: StickAction; + rightStickY: StickAction; + + // Inversion flags for each axis + invertLeftStickX: boolean; + invertLeftStickY: boolean; + invertRightStickX: boolean; + invertRightStickY: boolean; + + // Button mappings + trigger: ButtonAction; + aButton: ButtonAction; + bButton: ButtonAction; + xButton: ButtonAction; + yButton: ButtonAction; + squeeze: ButtonAction; +} + +/** + * Singleton configuration manager for controller mappings + * Handles loading, saving, and validation of controller configurations + */ +export class ControllerMappingConfig { + private static _instance: ControllerMappingConfig | null = null; + private _mapping: ControllerMapping; + + /** + * Default controller mapping (matches original game behavior) + */ + private static readonly DEFAULT_MAPPING: ControllerMapping = { + // Stick mappings (original behavior) + leftStickX: 'yaw', + leftStickY: 'forward', + rightStickX: 'roll', + rightStickY: 'pitch', + + // No inversions by default + invertLeftStickX: false, + invertLeftStickY: false, + invertRightStickX: false, + invertRightStickY: false, + + // Button mappings (original behavior) + trigger: 'fire', + aButton: 'cameraDown', + bButton: 'cameraUp', + xButton: 'statusScreen', + yButton: 'none', + squeeze: 'none', + }; + + private constructor() { + this._mapping = { ...ControllerMappingConfig.DEFAULT_MAPPING }; + this.loadFromStorage(); + } + + /** + * Get singleton instance + */ + public static getInstance(): ControllerMappingConfig { + if (!ControllerMappingConfig._instance) { + ControllerMappingConfig._instance = new ControllerMappingConfig(); + } + return ControllerMappingConfig._instance; + } + + /** + * Get current mapping configuration + */ + public getMapping(): Readonly { + return { ...this._mapping }; + } + + /** + * Update mapping configuration + */ + public setMapping(mapping: ControllerMapping): void { + this._mapping = { ...mapping }; + debugLog('[ControllerMapping] Configuration updated:', this._mapping); + } + + /** + * Reset to default mapping + */ + public resetToDefault(): void { + this._mapping = { ...ControllerMappingConfig.DEFAULT_MAPPING }; + debugLog('[ControllerMapping] Reset to default configuration'); + } + + /** + * Save current mapping to localStorage + */ + public save(): void { + try { + const json = JSON.stringify(this._mapping); + localStorage.setItem(STORAGE_KEY, json); + debugLog('[ControllerMapping] Saved to localStorage'); + } catch (error) { + console.error('[ControllerMapping] Failed to save to localStorage:', error); + } + } + + /** + * Load mapping from localStorage + */ + private loadFromStorage(): void { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + + // Merge with defaults to handle missing properties (backward compatibility) + this._mapping = { + ...ControllerMappingConfig.DEFAULT_MAPPING, + ...parsed, + }; + + debugLog('[ControllerMapping] Loaded from localStorage:', this._mapping); + } else { + debugLog('[ControllerMapping] No saved configuration, using defaults'); + } + } catch (error) { + console.warn('[ControllerMapping] Failed to load from localStorage, using defaults:', error); + this._mapping = { ...ControllerMappingConfig.DEFAULT_MAPPING }; + } + } + + /** + * Validate mapping configuration + * Returns array of warning messages (empty if valid) + */ + public validate(): string[] { + const warnings: string[] = []; + + // Check if fire action is mapped + const hasFireAction = this._mapping.trigger === 'fire' || + this._mapping.aButton === 'fire' || + this._mapping.bButton === 'fire' || + this._mapping.xButton === 'fire' || + this._mapping.yButton === 'fire' || + this._mapping.squeeze === 'fire'; + + if (!hasFireAction) { + warnings.push('Warning: No button is mapped to "Fire Weapon"'); + } + + // Check if forward thrust is mapped + const hasForwardAction = this._mapping.leftStickX === 'forward' || + this._mapping.leftStickY === 'forward' || + this._mapping.rightStickX === 'forward' || + this._mapping.rightStickY === 'forward'; + + if (!hasForwardAction) { + warnings.push('Warning: No stick is mapped to "Forward Thrust"'); + } + + // Check for duplicate stick actions (excluding 'none') + const stickActions = [ + this._mapping.leftStickX, + this._mapping.leftStickY, + this._mapping.rightStickX, + this._mapping.rightStickY, + ].filter(action => action !== 'none'); + + const duplicateStickActions = stickActions.filter((action, index) => + stickActions.indexOf(action) !== index + ); + + if (duplicateStickActions.length > 0) { + const unique = Array.from(new Set(duplicateStickActions)); + warnings.push(`Warning: Multiple sticks mapped to same action: ${unique.join(', ')}`); + } + + // Check for duplicate button actions (excluding 'none') + const buttonActions = [ + this._mapping.trigger, + this._mapping.aButton, + this._mapping.bButton, + this._mapping.xButton, + this._mapping.yButton, + this._mapping.squeeze, + ].filter(action => action !== 'none'); + + const duplicateButtonActions = buttonActions.filter((action, index) => + buttonActions.indexOf(action) !== index + ); + + if (duplicateButtonActions.length > 0) { + const unique = Array.from(new Set(duplicateButtonActions)); + warnings.push(`Warning: Multiple buttons mapped to same action: ${unique.join(', ')}`); + } + + return warnings; + } + + /** + * Get human-readable label for a stick action + */ + public static getStickActionLabel(action: StickAction): string { + switch (action) { + case 'yaw': return 'Yaw (Turn Left/Right)'; + case 'pitch': return 'Pitch (Nose Up/Down)'; + case 'roll': return 'Roll (Barrel Roll)'; + case 'forward': return 'Forward/Backward Thrust'; + case 'none': return 'None'; + default: return action; + } + } + + /** + * Get human-readable label for a button action + */ + public static getButtonActionLabel(action: ButtonAction): string { + switch (action) { + case 'fire': return 'Fire Weapon'; + case 'cameraUp': return 'Camera Adjust Up'; + case 'cameraDown': return 'Camera Adjust Down'; + case 'statusScreen': return 'Toggle Status Screen'; + case 'none': return 'None'; + default: return action; + } + } + + /** + * Get all available stick actions + */ + public static getAvailableStickActions(): StickAction[] { + return ['yaw', 'pitch', 'roll', 'forward', 'none']; + } + + /** + * Get all available button actions + */ + public static getAvailableButtonActions(): ButtonAction[] { + return ['fire', 'cameraUp', 'cameraDown', 'statusScreen', 'none']; + } +} diff --git a/src/ui/screens/controlsScreen.ts b/src/ui/screens/controlsScreen.ts new file mode 100644 index 0000000..bd37195 --- /dev/null +++ b/src/ui/screens/controlsScreen.ts @@ -0,0 +1,294 @@ +import { + ControllerMappingConfig, + ControllerMapping, + StickAction, + ButtonAction +} from '../../ship/input/controllerMapping'; + +/** + * Controller remapping screen + * Allows users to customize VR controller button and stick mappings + */ +export class ControlsScreen { + private config: ControllerMappingConfig; + private messageDiv: HTMLElement | null = null; + + constructor() { + this.config = ControllerMappingConfig.getInstance(); + } + + /** + * Initialize the controls screen + * Set up event listeners and populate form with current configuration + */ + public initialize(): void { + console.log('[ControlsScreen] Initializing'); + + // Get form elements + this.messageDiv = document.getElementById('controlsMessage'); + + // Populate dropdowns + this.populateDropdowns(); + + // Load current configuration into form + this.loadCurrentMapping(); + + // Set up event listeners + this.setupEventListeners(); + + console.log('[ControlsScreen] Initialized'); + } + + /** + * Populate all dropdown select elements with available actions + */ + private populateDropdowns(): void { + // Stick action dropdowns + const stickSelects = [ + 'leftStickX', 'leftStickY', + 'rightStickX', 'rightStickY' + ]; + + const stickActions = ControllerMappingConfig.getAvailableStickActions(); + + stickSelects.forEach(id => { + const select = document.getElementById(id) as HTMLSelectElement; + if (select) { + select.innerHTML = ''; + stickActions.forEach(action => { + const option = document.createElement('option'); + option.value = action; + option.textContent = ControllerMappingConfig.getStickActionLabel(action); + select.appendChild(option); + }); + } + }); + + // Button action dropdowns + const buttonSelects = [ + 'trigger', 'aButton', 'bButton', + 'xButton', 'yButton', 'squeeze' + ]; + + const buttonActions = ControllerMappingConfig.getAvailableButtonActions(); + + buttonSelects.forEach(id => { + const select = document.getElementById(id) as HTMLSelectElement; + if (select) { + select.innerHTML = ''; + buttonActions.forEach(action => { + const option = document.createElement('option'); + option.value = action; + option.textContent = ControllerMappingConfig.getButtonActionLabel(action); + select.appendChild(option); + }); + } + }); + } + + /** + * Load current mapping configuration into form elements + */ + private loadCurrentMapping(): void { + const mapping = this.config.getMapping(); + + // Stick mappings + this.setSelectValue('leftStickX', mapping.leftStickX); + this.setSelectValue('leftStickY', mapping.leftStickY); + this.setSelectValue('rightStickX', mapping.rightStickX); + this.setSelectValue('rightStickY', mapping.rightStickY); + + // Inversion checkboxes + this.setCheckboxValue('invertLeftStickX', mapping.invertLeftStickX); + this.setCheckboxValue('invertLeftStickY', mapping.invertLeftStickY); + this.setCheckboxValue('invertRightStickX', mapping.invertRightStickX); + this.setCheckboxValue('invertRightStickY', mapping.invertRightStickY); + + // Button mappings + this.setSelectValue('trigger', mapping.trigger); + this.setSelectValue('aButton', mapping.aButton); + this.setSelectValue('bButton', mapping.bButton); + this.setSelectValue('xButton', mapping.xButton); + this.setSelectValue('yButton', mapping.yButton); + this.setSelectValue('squeeze', mapping.squeeze); + + console.log('[ControlsScreen] Loaded current mapping into form'); + } + + /** + * Set up event listeners for buttons + */ + private setupEventListeners(): void { + // Save button + const saveBtn = document.getElementById('saveControlsBtn'); + if (saveBtn) { + saveBtn.addEventListener('click', () => this.saveMapping()); + } + + // Reset button + const resetBtn = document.getElementById('resetControlsBtn'); + if (resetBtn) { + resetBtn.addEventListener('click', () => this.resetToDefault()); + } + + // Test button (shows current mapping preview) + const testBtn = document.getElementById('testControlsBtn'); + if (testBtn) { + testBtn.addEventListener('click', () => this.showTestPreview()); + } + } + + /** + * Save current form values to configuration + */ + private saveMapping(): void { + // Read all form values + const mapping: ControllerMapping = { + // Stick mappings + leftStickX: this.getSelectValue('leftStickX') as StickAction, + leftStickY: this.getSelectValue('leftStickY') as StickAction, + rightStickX: this.getSelectValue('rightStickX') as StickAction, + rightStickY: this.getSelectValue('rightStickY') as StickAction, + + // Inversions + invertLeftStickX: this.getCheckboxValue('invertLeftStickX'), + invertLeftStickY: this.getCheckboxValue('invertLeftStickY'), + invertRightStickX: this.getCheckboxValue('invertRightStickX'), + invertRightStickY: this.getCheckboxValue('invertRightStickY'), + + // Button mappings + trigger: this.getSelectValue('trigger') as ButtonAction, + aButton: this.getSelectValue('aButton') as ButtonAction, + bButton: this.getSelectValue('bButton') as ButtonAction, + xButton: this.getSelectValue('xButton') as ButtonAction, + yButton: this.getSelectValue('yButton') as ButtonAction, + squeeze: this.getSelectValue('squeeze') as ButtonAction, + }; + + // Validate + this.config.setMapping(mapping); + const warnings = this.config.validate(); + + if (warnings.length > 0) { + // Show warnings but still save + this.showMessage( + 'Configuration saved with warnings:\n' + warnings.join('\n'), + 'warning' + ); + } else { + this.showMessage('Configuration saved successfully!', 'success'); + } + + // Save to localStorage + this.config.save(); + + console.log('[ControlsScreen] Saved mapping:', mapping); + } + + /** + * Reset form to default mapping + */ + private resetToDefault(): void { + if (confirm('Reset all controller mappings to default? This cannot be undone.')) { + this.config.resetToDefault(); + this.config.save(); + this.loadCurrentMapping(); + this.showMessage('Reset to default configuration', 'success'); + console.log('[ControlsScreen] Reset to defaults'); + } + } + + /** + * Show test preview of current mapping + */ + private showTestPreview(): void { + const mapping = this.readCurrentFormValues(); + + let preview = 'Current Controller Mapping:\n\n'; + + preview += 'šŸ“‹ STICK MAPPINGS:\n'; + preview += ` Left Stick X: ${ControllerMappingConfig.getStickActionLabel(mapping.leftStickX)}`; + preview += mapping.invertLeftStickX ? ' (Inverted)\n' : '\n'; + preview += ` Left Stick Y: ${ControllerMappingConfig.getStickActionLabel(mapping.leftStickY)}`; + preview += mapping.invertLeftStickY ? ' (Inverted)\n' : '\n'; + preview += ` Right Stick X: ${ControllerMappingConfig.getStickActionLabel(mapping.rightStickX)}`; + preview += mapping.invertRightStickX ? ' (Inverted)\n' : '\n'; + preview += ` Right Stick Y: ${ControllerMappingConfig.getStickActionLabel(mapping.rightStickY)}`; + preview += mapping.invertRightStickY ? ' (Inverted)\n' : '\n'; + + preview += '\nšŸŽ® BUTTON MAPPINGS:\n'; + preview += ` Trigger: ${ControllerMappingConfig.getButtonActionLabel(mapping.trigger)}\n`; + preview += ` A Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.aButton)}\n`; + preview += ` B Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.bButton)}\n`; + preview += ` X Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.xButton)}\n`; + preview += ` Y Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.yButton)}\n`; + preview += ` Squeeze/Grip: ${ControllerMappingConfig.getButtonActionLabel(mapping.squeeze)}\n`; + + alert(preview); + } + + /** + * Read current form values into a mapping object + */ + private readCurrentFormValues(): ControllerMapping { + return { + leftStickX: this.getSelectValue('leftStickX') as StickAction, + leftStickY: this.getSelectValue('leftStickY') as StickAction, + rightStickX: this.getSelectValue('rightStickX') as StickAction, + rightStickY: this.getSelectValue('rightStickY') as StickAction, + invertLeftStickX: this.getCheckboxValue('invertLeftStickX'), + invertLeftStickY: this.getCheckboxValue('invertLeftStickY'), + invertRightStickX: this.getCheckboxValue('invertRightStickX'), + invertRightStickY: this.getCheckboxValue('invertRightStickY'), + trigger: this.getSelectValue('trigger') as ButtonAction, + aButton: this.getSelectValue('aButton') as ButtonAction, + bButton: this.getSelectValue('bButton') as ButtonAction, + xButton: this.getSelectValue('xButton') as ButtonAction, + yButton: this.getSelectValue('yButton') as ButtonAction, + squeeze: this.getSelectValue('squeeze') as ButtonAction, + }; + } + + /** + * Show a message to the user + */ + private showMessage(message: string, type: 'success' | 'error' | 'warning' = 'success'): void { + if (this.messageDiv) { + this.messageDiv.textContent = message; + this.messageDiv.className = `controls-message ${type}`; + this.messageDiv.style.display = 'block'; + + // Hide after 5 seconds + setTimeout(() => { + if (this.messageDiv) { + this.messageDiv.style.display = 'none'; + } + }, 5000); + } + } + + // Helper methods for form manipulation + private setSelectValue(id: string, value: string): void { + const select = document.getElementById(id) as HTMLSelectElement; + if (select) { + select.value = value; + } + } + + private getSelectValue(id: string): string { + const select = document.getElementById(id) as HTMLSelectElement; + return select ? select.value : ''; + } + + private setCheckboxValue(id: string, checked: boolean): void { + const checkbox = document.getElementById(id) as HTMLInputElement; + if (checkbox) { + checkbox.checked = checked; + } + } + + private getCheckboxValue(id: string): boolean { + const checkbox = document.getElementById(id) as HTMLInputElement; + return checkbox ? checkbox.checked : false; + } +}