From 31b498da7d9c9c54f534273bc303ef4a2d60dffb Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sun, 9 Nov 2025 09:55:03 -0600 Subject: [PATCH] Add status screen pause functionality with VR controller picking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive status screen system with pause/resume and game-end states: - Added enable/disable functionality to controller and keyboard input systems - X button and inspector key always work, even when controls disabled - Created Resume/Replay/Exit VR buttons in status screen - Resume button appears on manual pause, Replay appears on game end - Implemented automatic status screen display on game end conditions: * Death: hull < 0.01 outside landing zone * Stranded: fuel < 0.01 and velocity < 1 outside landing zone * Victory: all asteroids destroyed inside landing zone - Fixed landing zone detection to use mesh intersection instead of distance - Implemented dynamic VR pointer selection using attach/detach pattern - Pointer selection only enabled when status screen is visible - Ship controls automatically disabled when status screen shows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/controllerInput.ts | 32 +++++++++ src/keyboardInput.ts | 55 +++++++++++---- src/main.ts | 30 ++++++++- src/ship.ts | 147 +++++++++++++++++++++++++++++++++++++---- src/statusScreen.ts | 138 +++++++++++++++++++++++++++++++++++++- 5 files changed, 370 insertions(+), 32 deletions(-) diff --git a/src/controllerInput.ts b/src/controllerInput.ts index dca821f..f260144 100644 --- a/src/controllerInput.ts +++ b/src/controllerInput.ts @@ -49,6 +49,7 @@ export class ControllerInput { private _onCameraAdjustObservable: Observable = new Observable(); private _onStatusScreenToggleObservable: Observable = new Observable(); + private _enabled: boolean = true; constructor() { this._controllerObservable.add(this.handleControllerEvent.bind(this)); @@ -79,12 +80,32 @@ export class ControllerInput { * Get current input state (stick positions) */ public getInputState() { + if (!this._enabled) { + return { + leftStick: Vector2.Zero(), + rightStick: Vector2.Zero(), + }; + } return { leftStick: this._leftStick.clone(), rightStick: this._rightStick.clone(), }; } + /** + * Enable or disable controller input + */ + public setEnabled(enabled: boolean): void { + this._enabled = enabled; + if (!enabled) { + // Reset stick values when disabled + this._leftStick.x = 0; + this._leftStick.y = 0; + this._rightStick.x = 0; + this._rightStick.y = 0; + } + } + /** * Add a VR controller to the input system */ @@ -199,6 +220,16 @@ export class ControllerInput { * Handle controller events (thumbsticks and buttons) */ private handleControllerEvent(controllerEvent: ControllerEvent): void { + // Don't process ship control inputs when disabled (but allow status screen toggle) + if (!this._enabled && controllerEvent.type === "thumbstick") { + return; + } + + if (!this._enabled && controllerEvent.type === "button" && + !(controllerEvent.component.id === "x-button" && controllerEvent.hand === "left")) { + return; + } + if (controllerEvent.type === "thumbstick") { if (controllerEvent.hand === "left") { this._leftStick.x = controllerEvent.axisData.x; @@ -235,6 +266,7 @@ export class ControllerInput { } if (controllerEvent.component.id === "x-button" && controllerEvent.hand === "left") { // Only trigger on button press, not release + // X button always works, even when disabled, to allow toggling status screen if (controllerEvent.pressed) { this._onStatusScreenToggleObservable.notifyObservers(); } diff --git a/src/keyboardInput.ts b/src/keyboardInput.ts index db90d55..8e763b4 100644 --- a/src/keyboardInput.ts +++ b/src/keyboardInput.ts @@ -21,6 +21,7 @@ export class KeyboardInput { private _onCameraChangeObservable: Observable = new Observable(); private _onRecordingActionObservable: Observable = new Observable(); private _scene: Scene; + private _enabled: boolean = true; constructor(scene: Scene) { this._scene = scene; @@ -51,12 +52,32 @@ export class KeyboardInput { * Get current input state (stick positions) */ public getInputState() { + if (!this._enabled) { + return { + leftStick: Vector2.Zero(), + rightStick: Vector2.Zero(), + }; + } return { leftStick: this._leftStick.clone(), rightStick: this._rightStick.clone(), }; } + /** + * Enable or disable keyboard input + */ + public setEnabled(enabled: boolean): void { + this._enabled = enabled; + if (!enabled) { + // Reset stick values when disabled + this._leftStick.x = 0; + this._leftStick.y = 0; + this._rightStick.x = 0; + this._rightStick.y = 0; + } + } + /** * Setup keyboard and mouse event listeners */ @@ -77,6 +98,28 @@ export class KeyboardInput { }; document.onkeydown = (ev) => { + // Always allow inspector and camera toggle, even when disabled + if (ev.key === 'i') { + // Open Babylon Inspector + import("@babylonjs/inspector").then((inspector) => { + inspector.Inspector.Show(this._scene, { + overlay: true, + showExplorer: true, + }); + }); + return; + } + + if (ev.key === '1') { + this._onCameraChangeObservable.notifyObservers(1); + return; + } + + // Don't process ship control inputs when disabled + if (!this._enabled) { + return; + } + // Recording controls (with modifiers) /*if (ev.key === 'r' || ev.key === 'R') { if (ev.ctrlKey || ev.metaKey) { @@ -96,18 +139,6 @@ export class KeyboardInput { }*/ switch (ev.key) { - case 'i': - // Open Babylon Inspector - import("@babylonjs/inspector").then((inspector) => { - inspector.Inspector.Show(this._scene, { - overlay: true, - showExplorer: true, - }); - }); - break; - case '1': - this._onCameraChangeObservable.notifyObservers(1); - break; case ' ': this._onShootObservable.notifyObservers(); break; diff --git a/src/main.ts b/src/main.ts index b099c9b..0c898bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -99,11 +99,21 @@ export class Main { this._currentLevel.getReadyObservable().add(async () => { setLoadingMessage("Starting game..."); + // Get ship and set up replay observable + const level1 = this._currentLevel as Level1; + const ship = (level1 as any)._ship; + + // Listen for replay requests from the ship + if (ship) { + ship.onReplayRequestObservable.add(() => { + debugLog('Replay requested - reloading page'); + window.location.reload(); + }); + } + // If we entered XR before level creation, manually setup camera parenting // (This is needed because onInitialXRPoseSetObservable won't fire if we're already in XR) if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2 - const level1 = this._currentLevel as Level1; - const ship = (level1 as any)._ship; if (ship && ship.transformNode) { debugLog('Manually parenting XR camera to ship transformNode'); @@ -315,7 +325,8 @@ export class Main { if (navigator.xr) { try { DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, { - disablePointerSelection: true, + // Don't disable pointer selection - we need it for status screen buttons + // Will detach it during gameplay and attach when status screen is shown disableTeleportation: true, disableNearInteraction: true, disableHandTracking: true, @@ -323,6 +334,19 @@ export class Main { }); debugLog(WebXRFeaturesManager.GetAvailableFeatures()); debugLog("WebXR initialized successfully"); + + // Store pointer selection feature reference and detach it initially + if (DefaultScene.XR) { + const pointerFeature = DefaultScene.XR.baseExperience.featuresManager.getEnabledFeature( + "xr-controller-pointer-selection" + ); + if (pointerFeature) { + (DefaultScene.XR as any).pointerSelectionFeature = pointerFeature; + // Detach immediately to prevent interaction during gameplay + pointerFeature.detach(); + debugLog("Pointer selection feature stored and detached"); + } + } } catch (error) { debugLog("WebXR initialization failed, falling back to flat mode:", error); DefaultScene.XR = null; diff --git a/src/ship.ts b/src/ship.ts index 797d359..4d08877 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -3,6 +3,7 @@ import { Color3, FreeCamera, Mesh, + Observable, PhysicsAggregate, PhysicsMotionType, PhysicsShapeType, @@ -52,6 +53,12 @@ export class Ship { private _isInLandingZone: boolean = false; private _isReplayMode: boolean; + // Observable for replay requests + public onReplayRequestObservable: Observable = new Observable(); + + // Auto-show status screen flag + private _statusScreenAutoShown: boolean = false; + constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) { this._audioEngine = audioEngine; this._isReplayMode = isReplayMode; @@ -69,6 +76,10 @@ export class Ship { return this._keyboardInput; } + public get isInLandingZone(): boolean { + return this._isInLandingZone; + } + public set position(newPosition: Vector3) { const body = this._ship.physicsBody; @@ -159,7 +170,17 @@ export class Ship { // Wire up status screen toggle event this._controllerInput.onStatusScreenToggleObservable.add(() => { if (this._statusScreen) { - this._statusScreen.toggle(); + if (this._statusScreen.isVisible) { + // Hide status screen and re-enable controls + this._statusScreen.hide(); + this._keyboardInput?.setEnabled(true); + this._controllerInput?.setEnabled(true); + } else { + // Show status screen (manual pause, not game end) and disable controls + this._statusScreen.show(false); + this._keyboardInput?.setEnabled(false); + this._controllerInput?.setEnabled(false); + } } }); @@ -195,6 +216,9 @@ export class Ship { this._frameCount = 0; this.updatePhysics(); } + + // Check game end conditions every frame (but only acts once) + this.checkGameEndConditions(); }); // Setup camera @@ -240,11 +264,101 @@ export class Ship { } }); - // Initialize status screen - this._statusScreen = new StatusScreen(DefaultScene.MainScene, this._gameStats); + // Initialize status screen with callbacks + this._statusScreen = new StatusScreen( + DefaultScene.MainScene, + this._gameStats, + () => this.handleReplayRequest(), + () => this.handleExitVR(), + () => this.handleResume() + ); this._statusScreen.initialize(this._camera); } + /** + * Handle replay button click from status screen + */ + private handleReplayRequest(): void { + debugLog('Replay button clicked - notifying observers'); + this.onReplayRequestObservable.notifyObservers(); + } + + /** + * Handle exit VR button click from status screen + */ + private handleExitVR(): void { + debugLog('Exit VR button clicked - refreshing browser'); + window.location.reload(); + } + + /** + * Handle resume button click from status screen + */ + private handleResume(): void { + debugLog('Resume button clicked - hiding status screen and re-enabling controls'); + this._statusScreen.hide(); + this._keyboardInput?.setEnabled(true); + this._controllerInput?.setEnabled(true); + } + + /** + * Check game-ending conditions and auto-show status screen + * Conditions: + * 1. Ship outside landing zone AND hull < 0.01 (death) + * 2. Ship outside landing zone AND fuel < 0.01 AND velocity < 1 (stranded) + * 3. All asteroids destroyed AND ship inside landing zone (victory) + */ + private checkGameEndConditions(): void { + // Skip if already auto-shown or status screen doesn't exist + if (this._statusScreenAutoShown || !this._statusScreen || !this._scoreboard) { + return; + } + + // Skip if no physics body yet + if (!this._ship?.physicsBody) { + return; + } + + // Get current ship status + const hull = this._scoreboard.shipStatus.hull; + const fuel = this._scoreboard.shipStatus.fuel; + const asteroidsRemaining = this._scoreboard.remaining; + + // Calculate total linear velocity + const linearVelocity = this._ship.physicsBody.getLinearVelocity(); + const totalVelocity = linearVelocity.length(); + + // Check condition 1: Death by hull damage (outside landing zone) + if (!this._isInLandingZone && hull < 0.01) { + debugLog('Game end condition met: Hull critical outside landing zone'); + this._statusScreen.show(true); + this._keyboardInput?.setEnabled(false); + this._controllerInput?.setEnabled(false); + this._statusScreenAutoShown = true; + return; + } + + // Check condition 2: Stranded (outside landing zone, no fuel, low velocity) + if (!this._isInLandingZone && fuel < 0.01 && totalVelocity < 1) { + debugLog('Game end condition met: Stranded (no fuel, low velocity)'); + this._statusScreen.show(true); + this._keyboardInput?.setEnabled(false); + this._controllerInput?.setEnabled(false); + this._statusScreenAutoShown = true; + return; + } + + // Check condition 3: Victory (all asteroids destroyed, inside landing zone) + if (asteroidsRemaining <= 0 && this._isInLandingZone) { + debugLog('Game end condition met: Victory (all asteroids destroyed)'); + this._statusScreen.show(true); + this._keyboardInput?.setEnabled(false); + this._controllerInput?.setEnabled(false); + this._statusScreenAutoShown = true; + return; + } + } + /** * Update physics based on combined input from all input sources */ @@ -302,16 +416,22 @@ export class Ship { return; } - // Check if ship is still in the landing zone by checking distance - // Since it's a trigger, we need to track position - const shipPos = this._ship.physicsBody.transformNode.position; - const landingPos = this._landingAggregate.transformNode.position; - const distance = Vector3.Distance(shipPos, landingPos); - - // Assume landing zone radius is approximately 20 units (adjust as needed) + // Check if ship mesh intersects with landing zone mesh const wasInZone = this._isInLandingZone; - this._isInLandingZone = distance < 20; + // Get the meshes from the transform nodes + const shipMesh = this._ship.getChildMeshes()[0]; + const landingMesh = this._landingAggregate.transformNode as Mesh; + + // Use mesh intersection for accurate zone detection + if (shipMesh && landingMesh) { + this._isInLandingZone = shipMesh.intersectsMesh(landingMesh, false); + } else { + // Fallback: if meshes not available, assume not in zone + this._isInLandingZone = false; + } + + // Log zone transitions if (this._isInLandingZone && !wasInZone) { debugLog("Ship entered landing zone - resupply active"); } else if (!this._isInLandingZone && wasInZone) { @@ -372,12 +492,11 @@ export class Ship { public setLandingZone(landingAggregate: PhysicsAggregate): void { this._landingAggregate = landingAggregate; - // Listen for trigger events to detect when ship enters/exits landing zone + // Listen for trigger events for debugging (actual detection uses mesh intersection) landingAggregate.body.getCollisionObservable().add((collisionEvent) => { // Check if the collision is with our ship if (collisionEvent.collider === this._ship.physicsBody) { - this._isInLandingZone = true; - debugLog("Ship entered landing zone - resupply active"); + debugLog("Physics trigger fired for landing zone"); } }); } diff --git a/src/statusScreen.ts b/src/statusScreen.ts index e8f4cba..d98ad9c 100644 --- a/src/statusScreen.ts +++ b/src/statusScreen.ts @@ -1,5 +1,6 @@ import { AdvancedDynamicTexture, + Button, Control, Rectangle, StackPanel, @@ -14,6 +15,7 @@ import { Vector3 } from "@babylonjs/core"; import { GameStats } from "./gameStats"; +import { DefaultScene } from "./defaultScene"; /** * Status screen that displays game statistics @@ -35,9 +37,25 @@ export class StatusScreen { private _accuracyText: TextBlock; private _fuelConsumedText: TextBlock; - constructor(scene: Scene, gameStats: GameStats) { + // Buttons + private _replayButton: Button; + private _exitButton: Button; + private _resumeButton: Button; + + // Callbacks + private _onReplayCallback: (() => void) | null = null; + private _onExitCallback: (() => void) | null = null; + private _onResumeCallback: (() => void) | null = null; + + // Track whether game has ended + private _isGameEnded: boolean = false; + + constructor(scene: Scene, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void) { this._scene = scene; this._gameStats = gameStats; + this._onReplayCallback = onReplay || null; + this._onExitCallback = onExit || null; + this._onResumeCallback = onResume || null; } /** @@ -108,6 +126,70 @@ export class StatusScreen { this._fuelConsumedText = this.createStatText("Fuel Consumed: 0%"); mainPanel.addControl(this._fuelConsumedText); + // Add spacing before buttons + const spacer2 = this.createSpacer(50); + mainPanel.addControl(spacer2); + + // Create button bar + const buttonBar = new StackPanel("buttonBar"); + buttonBar.isVertical = false; + buttonBar.height = "80px"; + buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + buttonBar.spacing = 20; + + // Create Resume button (only shown when game hasn't ended) + this._resumeButton = Button.CreateSimpleButton("resumeButton", "RESUME GAME"); + this._resumeButton.width = "300px"; + this._resumeButton.height = "60px"; + this._resumeButton.color = "white"; + this._resumeButton.background = "#00ff88"; + this._resumeButton.cornerRadius = 10; + this._resumeButton.thickness = 0; + this._resumeButton.fontSize = "30px"; + this._resumeButton.fontWeight = "bold"; + this._resumeButton.onPointerClickObservable.add(() => { + if (this._onResumeCallback) { + this._onResumeCallback(); + } + }); + buttonBar.addControl(this._resumeButton); + + // Create Replay button (only shown when game has ended) + this._replayButton = Button.CreateSimpleButton("replayButton", "REPLAY LEVEL"); + this._replayButton.width = "300px"; + this._replayButton.height = "60px"; + this._replayButton.color = "white"; + this._replayButton.background = "#00ff88"; + this._replayButton.cornerRadius = 10; + this._replayButton.thickness = 0; + this._replayButton.fontSize = "30px"; + this._replayButton.fontWeight = "bold"; + this._replayButton.onPointerClickObservable.add(() => { + if (this._onReplayCallback) { + this._onReplayCallback(); + } + }); + buttonBar.addControl(this._replayButton); + + // Create Exit VR button + this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT VR"); + this._exitButton.width = "300px"; + this._exitButton.height = "60px"; + this._exitButton.color = "white"; + this._exitButton.background = "#cc3333"; + this._exitButton.cornerRadius = 10; + this._exitButton.thickness = 0; + this._exitButton.fontSize = "30px"; + this._exitButton.fontWeight = "bold"; + this._exitButton.onPointerClickObservable.add(() => { + if (this._onExitCallback) { + this._onExitCallback(); + } + }); + buttonBar.addControl(this._exitButton); + + mainPanel.addControl(buttonBar); + this._texture.addControl(mainPanel); // Initially hide the screen @@ -166,13 +248,60 @@ export class StatusScreen { } /** - * Show the status screen + * Enable VR controller picking for button interaction */ - public show(): void { + 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); + } + } + } + + /** + * Show the status screen + * @param isGameEnded - true if game has ended (death/stranded/victory), false if manually paused + */ + public show(isGameEnded: boolean = false): void { if (!this._screenMesh) { return; } + // Store game ended state + this._isGameEnded = isGameEnded; + + // Show/hide appropriate buttons based on whether game has ended + if (this._resumeButton) { + this._resumeButton.isVisible = !isGameEnded; + } + if (this._replayButton) { + this._replayButton.isVisible = isGameEnded; + } + + // Enable pointer selection for button interaction + this.enablePointerSelection(); + // Update statistics before showing this.updateStatistics(); @@ -189,6 +318,9 @@ export class StatusScreen { return; } + // Disable pointer selection when hiding + this.disablePointerSelection(); + this._screenMesh.setEnabled(false); this._isVisible = false; }