diff --git a/package-lock.json b/package-lock.json index ca0c1f5..b452ce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@babylonjs/procedural-textures": "8.36.1", "@babylonjs/serializers": "8.36.1", "@newrelic/browser-agent": "^1.302.0", + "loglevel": "^1.9.2", "openai": "4.52.3", "svelte-spa-router": "^4.0.1" }, @@ -1431,6 +1432,18 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 02884d4..cff7e2c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@babylonjs/serializers": "8.36.1", "@newrelic/browser-agent": "^1.302.0", "openai": "4.52.3", + "loglevel": "^1.9.2", "svelte-spa-router": "^4.0.1" }, "devDependencies": { diff --git a/src/levels/level1.ts b/src/levels/level1.ts index ab6c72d..8fed929 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -18,6 +18,7 @@ import {PhysicsRecorder} from "../replay/recording/physicsRecorder"; import {getAnalytics} from "../analytics"; import {MissionBrief} from "../ui/hud/missionBrief"; import {LevelRegistry, LevelDirectoryEntry} from "./storage/levelRegistry"; +import { InputControlManager } from "../ship/input/inputControlManager"; export class Level1 implements Level { private _ship: Ship; @@ -162,12 +163,13 @@ export class Level1 implements Level { // Disable ship controls while mission brief is showing debugLog('[Level1] Disabling ship controls for mission brief'); - this._ship.disableControls(); + const inputManager = InputControlManager.getInstance(); + inputManager.disableShipControls("MissionBrief"); // Show mission brief with trigger observable this._missionBrief.show(this._levelConfig, directoryEntry, this._ship.onMissionBriefTriggerObservable, () => { debugLog('[Level1] Mission brief dismissed - enabling controls and starting game'); - this._ship.enableControls(); + inputManager.enableShipControls("MissionBrief"); this.startGameplay(); }); } @@ -343,10 +345,10 @@ export class Level1 implements Level { // Load background music before marking as ready if (this._audioEngine) { setLoadingMessage("Loading background music..."); - /*this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { + this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { loop: true, volume: 0.5 - });*/ + }); debugLog('Background music loaded successfully'); } diff --git a/src/main.ts b/src/main.ts index 3894ae0..4ad7d9a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ import App from './components/layouts/App.svelte'; import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent' import { AnalyticsService } from './analytics/analyticsService'; import { NewRelicAdapter } from './analytics/adapters/newRelicAdapter'; +import { InputControlManager } from './ship/input/inputControlManager'; // Populate using values from NerdGraph const options = { @@ -524,16 +525,19 @@ export class Main { debugLog(WebXRFeaturesManager.GetAvailableFeatures()); debugLog("WebXR initialized successfully"); - // Store pointer selection feature reference and detach it initially + // Register pointer selection feature with InputControlManager if (DefaultScene.XR) { const pointerFeature = DefaultScene.XR.baseExperience.featuresManager.getEnabledFeature( "xr-controller-pointer-selection" ); if (pointerFeature) { + // Store for backward compatibility (can be removed later if not needed) (DefaultScene.XR as any).pointerSelectionFeature = pointerFeature; - // Detach immediately to prevent interaction during gameplay - pointerFeature.detach(); - debugLog("Pointer selection feature stored and detached"); + + // Register with InputControlManager + const inputManager = InputControlManager.getInstance(); + inputManager.registerPointerFeature(pointerFeature); + debugLog("Pointer selection feature registered with InputControlManager"); } // Hide Discord widget when entering VR, show when exiting diff --git a/src/ship/input/controllerInput.ts b/src/ship/input/controllerInput.ts index decbbab..2020963 100644 --- a/src/ship/input/controllerInput.ts +++ b/src/ship/input/controllerInput.ts @@ -274,10 +274,14 @@ export class ControllerInput { return; } - if (!this._enabled && controllerEvent.type === "button" && - !(controllerEvent.component.id === "x-button" && controllerEvent.hand === "left") && - controllerEvent.component.type !== "trigger") { - return; + if (!this._enabled && controllerEvent.type === "button") { + // Only allow X-button on left controller (for status screen toggle) + if (controllerEvent.component.id === "x-button" && controllerEvent.hand === "left") { + // Allow this through + } else { + // Block all other buttons including triggers + return; + } } if (controllerEvent.type === "thumbstick") { @@ -295,6 +299,9 @@ export class ControllerInput { if (controllerEvent.type === "button") { if (controllerEvent.component.type === "trigger") { + if (!this._enabled) { + return; + } if (controllerEvent.value > 0.9 && !this._shooting) { this._shooting = true; this._onShootObservable.notifyObservers(); diff --git a/src/ship/input/inputControlManager.ts b/src/ship/input/inputControlManager.ts new file mode 100644 index 0000000..83637ba --- /dev/null +++ b/src/ship/input/inputControlManager.ts @@ -0,0 +1,208 @@ +import { Observable } from "@babylonjs/core"; +import { KeyboardInput } from "./keyboardInput"; +import { ControllerInput } from "./controllerInput"; +import debugLog from "../../core/debug"; + +/** + * State change event emitted when ship controls or pointer selection state changes + */ +export interface InputControlStateChange { + shipControlsEnabled: boolean; + pointerSelectionEnabled: boolean; + requester: string; // e.g., "StatusScreen", "MissionBrief", "Level1" + timestamp: number; +} + +/** + * Centralized manager for ship controls and pointer selection + * Ensures ship controls and pointer selection are mutually exclusive + * Emits events when state changes for debugging and analytics + * + * Design principles: + * - Last-wins behavior: Most recent state change takes precedence + * - Mutually exclusive: Ship controls and pointer selection are inverses + * - Event-driven: Emits observables when state changes + * - Centralized: Single source of truth via singleton pattern + */ +export class InputControlManager { + private static _instance: InputControlManager | null = null; + + private _shipControlsEnabled: boolean = true; + private _pointerSelectionEnabled: boolean = false; + + // Observable for state changes + private _onStateChangedObservable: Observable = new Observable(); + + // References to systems we control + private _keyboardInput: KeyboardInput | null = null; + private _controllerInput: ControllerInput | null = null; + private _xrPointerFeature: any = null; + + /** + * Private constructor for singleton pattern + */ + private constructor() { + debugLog('[InputControlManager] Instance created'); + } + + /** + * Get singleton instance + */ + public static getInstance(): InputControlManager { + if (!InputControlManager._instance) { + InputControlManager._instance = new InputControlManager(); + } + return InputControlManager._instance; + } + + /** + * Register input systems (called by Ship during initialization) + */ + public registerInputSystems(keyboard: KeyboardInput | null, controller: ControllerInput | null): void { + debugLog('[InputControlManager] Registering input systems', { keyboard: !!keyboard, controller: !!controller }); + this._keyboardInput = keyboard; + this._controllerInput = controller; + } + + /** + * Register XR pointer feature (called by main.ts during XR setup) + */ + public registerPointerFeature(pointerFeature: any): void { + debugLog('[InputControlManager] Registering XR pointer feature'); + this._xrPointerFeature = pointerFeature; + + // Apply current state to the newly registered pointer feature + this.updatePointerFeature(); + } + + /** + * Enable ship controls, disable pointer selection + */ + public enableShipControls(requester: string): void { + debugLog(`[InputControlManager] Enabling ship controls (requester: ${requester})`); + + // Update state + this._shipControlsEnabled = true; + this._pointerSelectionEnabled = false; + + // Apply to input systems + if (this._keyboardInput) { + this._keyboardInput.setEnabled(true); + } + if (this._controllerInput) { + this._controllerInput.setEnabled(true); + } + + // Disable pointer selection + this.updatePointerFeature(); + + // Emit state change event + this.emitStateChange(requester); + } + + /** + * Disable ship controls, enable pointer selection + */ + public disableShipControls(requester: string): void { + debugLog(`[InputControlManager] Disabling ship controls (requester: ${requester})`); + + // Update state + this._shipControlsEnabled = false; + this._pointerSelectionEnabled = true; + + // Apply to input systems + if (this._keyboardInput) { + this._keyboardInput.setEnabled(false); + } + if (this._controllerInput) { + this._controllerInput.setEnabled(false); + } + + // Enable pointer selection + this.updatePointerFeature(); + + // Emit state change event + this.emitStateChange(requester); + } + + /** + * Update XR pointer feature state based on current settings + */ + private updatePointerFeature(): void { + if (!this._xrPointerFeature) { + return; + } + + try { + if (this._pointerSelectionEnabled) { + // Enable pointer selection (attach feature) + this._xrPointerFeature.attach(); + debugLog('[InputControlManager] Pointer selection enabled'); + } else { + // Disable pointer selection (detach feature) + this._xrPointerFeature.detach(); + debugLog('[InputControlManager] Pointer selection disabled'); + } + } catch (error) { + console.warn('[InputControlManager] Failed to update pointer feature:', error); + } + } + + /** + * Emit state change event + */ + private emitStateChange(requester: string): void { + const stateChange: InputControlStateChange = { + shipControlsEnabled: this._shipControlsEnabled, + pointerSelectionEnabled: this._pointerSelectionEnabled, + requester: requester, + timestamp: Date.now() + }; + + this._onStateChangedObservable.notifyObservers(stateChange); + + debugLog('[InputControlManager] State changed:', stateChange); + } + + /** + * Get current ship controls enabled state + */ + public get shipControlsEnabled(): boolean { + return this._shipControlsEnabled; + } + + /** + * Get current pointer selection enabled state + */ + public get pointerSelectionEnabled(): boolean { + return this._pointerSelectionEnabled; + } + + /** + * Get observable for state changes + */ + public get onStateChanged(): Observable { + return this._onStateChangedObservable; + } + + /** + * Cleanup (for testing or hot reload) + */ + public dispose(): void { + debugLog('[InputControlManager] Disposing'); + this._onStateChangedObservable.clear(); + this._keyboardInput = null; + this._controllerInput = null; + this._xrPointerFeature = null; + } + + /** + * Reset singleton instance (for testing) + */ + public static reset(): void { + if (InputControlManager._instance) { + InputControlManager._instance.dispose(); + InputControlManager._instance = null; + } + } +} diff --git a/src/ship/ship.ts b/src/ship/ship.ts index abbf110..b8df7ca 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -29,6 +29,7 @@ import { WeaponSystem } from "./weaponSystem"; import { StatusScreen } from "../ui/hud/statusScreen"; import { GameStats } from "../game/gameStats"; import { getAnalytics } from "../analytics"; +import { InputControlManager } from "./input/inputControlManager"; export class Ship { private _ship: TransformNode; @@ -124,6 +125,7 @@ export class Ship { this._ship = new TransformNode("shipBase", DefaultScene.MainScene); const data = await loadAsset("ship.glb"); this._ship = data.container.transformNodes[0]; + // this._ship.id = "Ship"; // Set ID so mission brief can find it this._ship.position.y = 5; // Create physics if enabled @@ -221,6 +223,10 @@ export class Ship { this._controllerInput = new ControllerInput(); + // Register input systems with InputControlManager + const inputManager = InputControlManager.getInstance(); + inputManager.registerInputSystems(this._keyboardInput, this._controllerInput); + // Wire up shooting events this._keyboardInput.onShootObservable.add(() => { this.handleShoot(); @@ -234,15 +240,12 @@ export class Ship { this._controllerInput.onStatusScreenToggleObservable.add(() => { if (this._statusScreen) { if (this._statusScreen.isVisible) { - // Hide status screen and re-enable controls + // Hide status screen - InputControlManager will handle control re-enabling this._statusScreen.hide(); - this._keyboardInput?.setEnabled(true); - this._controllerInput?.setEnabled(true); } else { - // Show status screen (manual pause, not game end) and disable controls + // Show status screen (manual pause, not game end) + // InputControlManager will handle control disabling this._statusScreen.show(false); - this._keyboardInput?.setEnabled(false); - this._controllerInput?.setEnabled(false); } } }); @@ -392,10 +395,9 @@ export class Ship { * Handle resume button click from status screen */ private handleResume(): void { - debugLog('Resume button clicked - hiding status screen and re-enabling controls'); + debugLog('Resume button clicked - hiding status screen'); + // InputControlManager will handle re-enabling controls when status screen hides this._statusScreen.hide(); - this._keyboardInput?.setEnabled(true); - this._controllerInput?.setEnabled(true); } /** @@ -439,8 +441,7 @@ export class Ship { if (!this._isInLandingZone && hull < 0.01) { debugLog('Game end condition met: Hull critical outside landing zone'); this._statusScreen.show(true, false); // Game ended, not victory - this._keyboardInput?.setEnabled(false); - this._controllerInput?.setEnabled(false); + // InputControlManager will handle disabling controls when status screen shows this._statusScreenAutoShown = true; return; } @@ -449,8 +450,7 @@ export class Ship { if (!this._isInLandingZone && fuel < 0.01 && totalVelocity < 5) { debugLog('Game end condition met: Stranded (no fuel, low velocity)'); this._statusScreen.show(true, false); // Game ended, not victory - this._keyboardInput?.setEnabled(false); - this._controllerInput?.setEnabled(false); + // InputControlManager will handle disabling controls when status screen shows this._statusScreenAutoShown = true; return; } @@ -459,8 +459,7 @@ export class Ship { if (asteroidsRemaining <= 0 && this._isInLandingZone) { debugLog('Game end condition met: Victory (all asteroids destroyed)'); this._statusScreen.show(true, true); // Game ended, VICTORY! - this._keyboardInput?.setEnabled(false); - this._controllerInput?.setEnabled(false); + // InputControlManager will handle disabling controls when status screen shows this._statusScreenAutoShown = true; return; } @@ -628,33 +627,6 @@ export class Ship { } } - /** - * Disable ship controls (for mission brief, etc.) - */ - public disableControls(): void { - debugLog('[Ship] Disabling controls'); - this._controlsEnabled = false; - if (this._controllerInput) { - this._controllerInput.setEnabled(false); - } - if (this._keyboardInput) { - this._keyboardInput.setEnabled(false); - } - } - - /** - * Enable ship controls - */ - public enableControls(): void { - debugLog('[Ship] Enabling controls'); - this._controlsEnabled = true; - if (this._controllerInput) { - this._controllerInput.setEnabled(true); - } - if (this._keyboardInput) { - this._keyboardInput.setEnabled(true); - } - } /** * Dispose of ship resources diff --git a/src/ui/hud/missionBrief.ts b/src/ui/hud/missionBrief.ts index 06c4c8f..f98f1db 100644 --- a/src/ui/hud/missionBrief.ts +++ b/src/ui/hud/missionBrief.ts @@ -1,5 +1,6 @@ import { AdvancedDynamicTexture, + Button, Control, Rectangle, StackPanel, @@ -199,16 +200,30 @@ export class MissionBrief { spacer3.thickness = 0; contentPanel.addControl(spacer3); - const startText = new TextBlock("startTExt"); - startText.text = 'Pull trigger to start'; - startText.color = "#00aa00"; - startText.fontSize = 48; - startText.textWrapping = true; - startText.height = "80px"; - startText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; - startText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; - startText.paddingLeft = "20px"; - contentPanel.addControl(startText); + // START button + const startButton = Button.CreateSimpleButton("startButton", "START MISSION"); + startButton.width = "400px"; + startButton.height = "60px"; + startButton.color = "white"; + startButton.background = "#00ff88"; + startButton.cornerRadius = 10; + startButton.thickness = 0; + startButton.fontSize = "36px"; + startButton.fontWeight = "bold"; + startButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + startButton.onPointerClickObservable.add(() => { + debugLog('[MissionBrief] START button clicked - dismissing mission brief'); + this.hide(); + if (this._onStartCallback) { + this._onStartCallback(); + } + // Remove trigger observer when button is clicked + if (this._triggerObserver) { + triggerObservable.remove(this._triggerObserver); + this._triggerObserver = null; + } + }); + contentPanel.addControl(startButton); // Show the container this._container.isVisible = true; diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts index 73248d3..e4bba41 100644 --- a/src/ui/hud/statusScreen.ts +++ b/src/ui/hud/statusScreen.ts @@ -19,6 +19,7 @@ import { DefaultScene } from "../../core/defaultScene"; import { ProgressionManager } from "../../game/progression"; import { AuthService } from "../../services/authService"; import { FacebookShare, ShareData } from "../../services/facebookShare"; +import { InputControlManager } from "../../ship/input/inputControlManager"; /** * Status screen that displays game statistics @@ -300,37 +301,6 @@ export class StatusScreen { } } - /** - * Enable VR controller picking for button interaction - */ - private enablePointerSelection(): void { - // Get the stored pointer selection feature - const pointerFeature = (DefaultScene.XR as any)?.pointerSelectionFeature; - if (pointerFeature && DefaultScene.XR?.baseExperience?.state === 2) { // WebXRState.IN_XR = 2 - try { - // Attach the feature to enable pointer interaction - pointerFeature.attach(); - } catch (error) { - console.warn('Failed to attach pointer selection:', error); - } - } - } - - /** - * Disable VR controller picking - */ - private disablePointerSelection(): void { - // Get the stored pointer selection feature - const pointerFeature = (DefaultScene.XR as any)?.pointerSelectionFeature; - if (pointerFeature) { - try { - // Detach the feature to disable pointer interaction - pointerFeature.detach(); - } catch (error) { - console.warn('Failed to detach pointer selection:', error); - } - } - } /** * Set the current level name for progression tracking @@ -394,8 +364,9 @@ export class StatusScreen { } } - // Enable pointer selection for button interaction - this.enablePointerSelection(); + // Disable ship controls and enable pointer selection via InputControlManager + const inputManager = InputControlManager.getInstance(); + inputManager.disableShipControls("StatusScreen"); // Update statistics before showing this.updateStatistics(); @@ -426,8 +397,9 @@ export class StatusScreen { return; } - // Disable pointer selection when hiding - this.disablePointerSelection(); + // Re-enable ship controls and disable pointer selection via InputControlManager + const inputManager = InputControlManager.getInstance(); + inputManager.enableShipControls("StatusScreen"); this._screenMesh.setEnabled(false); this._isVisible = false; diff --git a/themes/default/base2.blend1 b/themes/default/base2.blend1 new file mode 100644 index 0000000..31df4bd Binary files /dev/null and b/themes/default/base2.blend1 differ