Add status screen pause functionality with VR controller picking
Some checks failed
Build / build (push) Failing after 24s

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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-09 09:55:03 -06:00
parent d6b1744ce4
commit 31b498da7d
5 changed files with 370 additions and 32 deletions

View File

@ -49,6 +49,7 @@ export class ControllerInput {
private _onCameraAdjustObservable: Observable<CameraAdjustment> = private _onCameraAdjustObservable: Observable<CameraAdjustment> =
new Observable<CameraAdjustment>(); new Observable<CameraAdjustment>();
private _onStatusScreenToggleObservable: Observable<void> = new Observable<void>(); private _onStatusScreenToggleObservable: Observable<void> = new Observable<void>();
private _enabled: boolean = true;
constructor() { constructor() {
this._controllerObservable.add(this.handleControllerEvent.bind(this)); this._controllerObservable.add(this.handleControllerEvent.bind(this));
@ -79,12 +80,32 @@ export class ControllerInput {
* Get current input state (stick positions) * Get current input state (stick positions)
*/ */
public getInputState() { public getInputState() {
if (!this._enabled) {
return {
leftStick: Vector2.Zero(),
rightStick: Vector2.Zero(),
};
}
return { return {
leftStick: this._leftStick.clone(), leftStick: this._leftStick.clone(),
rightStick: this._rightStick.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 * Add a VR controller to the input system
*/ */
@ -199,6 +220,16 @@ export class ControllerInput {
* Handle controller events (thumbsticks and buttons) * Handle controller events (thumbsticks and buttons)
*/ */
private handleControllerEvent(controllerEvent: ControllerEvent): void { 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.type === "thumbstick") {
if (controllerEvent.hand === "left") { if (controllerEvent.hand === "left") {
this._leftStick.x = controllerEvent.axisData.x; this._leftStick.x = controllerEvent.axisData.x;
@ -235,6 +266,7 @@ export class ControllerInput {
} }
if (controllerEvent.component.id === "x-button" && controllerEvent.hand === "left") { if (controllerEvent.component.id === "x-button" && controllerEvent.hand === "left") {
// Only trigger on button press, not release // Only trigger on button press, not release
// X button always works, even when disabled, to allow toggling status screen
if (controllerEvent.pressed) { if (controllerEvent.pressed) {
this._onStatusScreenToggleObservable.notifyObservers(); this._onStatusScreenToggleObservable.notifyObservers();
} }

View File

@ -21,6 +21,7 @@ export class KeyboardInput {
private _onCameraChangeObservable: Observable<number> = new Observable<number>(); private _onCameraChangeObservable: Observable<number> = new Observable<number>();
private _onRecordingActionObservable: Observable<RecordingAction> = new Observable<RecordingAction>(); private _onRecordingActionObservable: Observable<RecordingAction> = new Observable<RecordingAction>();
private _scene: Scene; private _scene: Scene;
private _enabled: boolean = true;
constructor(scene: Scene) { constructor(scene: Scene) {
this._scene = scene; this._scene = scene;
@ -51,12 +52,32 @@ export class KeyboardInput {
* Get current input state (stick positions) * Get current input state (stick positions)
*/ */
public getInputState() { public getInputState() {
if (!this._enabled) {
return {
leftStick: Vector2.Zero(),
rightStick: Vector2.Zero(),
};
}
return { return {
leftStick: this._leftStick.clone(), leftStick: this._leftStick.clone(),
rightStick: this._rightStick.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 * Setup keyboard and mouse event listeners
*/ */
@ -77,6 +98,28 @@ export class KeyboardInput {
}; };
document.onkeydown = (ev) => { 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) // Recording controls (with modifiers)
/*if (ev.key === 'r' || ev.key === 'R') { /*if (ev.key === 'r' || ev.key === 'R') {
if (ev.ctrlKey || ev.metaKey) { if (ev.ctrlKey || ev.metaKey) {
@ -96,18 +139,6 @@ export class KeyboardInput {
}*/ }*/
switch (ev.key) { 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 ' ': case ' ':
this._onShootObservable.notifyObservers(); this._onShootObservable.notifyObservers();
break; break;

View File

@ -99,11 +99,21 @@ export class Main {
this._currentLevel.getReadyObservable().add(async () => { this._currentLevel.getReadyObservable().add(async () => {
setLoadingMessage("Starting game..."); 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 // 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) // (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 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) { if (ship && ship.transformNode) {
debugLog('Manually parenting XR camera to ship transformNode'); debugLog('Manually parenting XR camera to ship transformNode');
@ -315,7 +325,8 @@ export class Main {
if (navigator.xr) { if (navigator.xr) {
try { try {
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, { 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, disableTeleportation: true,
disableNearInteraction: true, disableNearInteraction: true,
disableHandTracking: true, disableHandTracking: true,
@ -323,6 +334,19 @@ export class Main {
}); });
debugLog(WebXRFeaturesManager.GetAvailableFeatures()); debugLog(WebXRFeaturesManager.GetAvailableFeatures());
debugLog("WebXR initialized successfully"); 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) { } catch (error) {
debugLog("WebXR initialization failed, falling back to flat mode:", error); debugLog("WebXR initialization failed, falling back to flat mode:", error);
DefaultScene.XR = null; DefaultScene.XR = null;

View File

@ -3,6 +3,7 @@ import {
Color3, Color3,
FreeCamera, FreeCamera,
Mesh, Mesh,
Observable,
PhysicsAggregate, PhysicsAggregate,
PhysicsMotionType, PhysicsMotionType,
PhysicsShapeType, PhysicsShapeType,
@ -52,6 +53,12 @@ export class Ship {
private _isInLandingZone: boolean = false; private _isInLandingZone: boolean = false;
private _isReplayMode: boolean; private _isReplayMode: boolean;
// Observable for replay requests
public onReplayRequestObservable: Observable<void> = new Observable<void>();
// Auto-show status screen flag
private _statusScreenAutoShown: boolean = false;
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) { constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
this._audioEngine = audioEngine; this._audioEngine = audioEngine;
this._isReplayMode = isReplayMode; this._isReplayMode = isReplayMode;
@ -69,6 +76,10 @@ export class Ship {
return this._keyboardInput; return this._keyboardInput;
} }
public get isInLandingZone(): boolean {
return this._isInLandingZone;
}
public set position(newPosition: Vector3) { public set position(newPosition: Vector3) {
const body = this._ship.physicsBody; const body = this._ship.physicsBody;
@ -159,7 +170,17 @@ export class Ship {
// Wire up status screen toggle event // Wire up status screen toggle event
this._controllerInput.onStatusScreenToggleObservable.add(() => { this._controllerInput.onStatusScreenToggleObservable.add(() => {
if (this._statusScreen) { 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._frameCount = 0;
this.updatePhysics(); this.updatePhysics();
} }
// Check game end conditions every frame (but only acts once)
this.checkGameEndConditions();
}); });
// Setup camera // Setup camera
@ -240,11 +264,101 @@ export class Ship {
} }
}); });
// Initialize status screen // Initialize status screen with callbacks
this._statusScreen = new StatusScreen(DefaultScene.MainScene, this._gameStats); this._statusScreen = new StatusScreen(
DefaultScene.MainScene,
this._gameStats,
() => this.handleReplayRequest(),
() => this.handleExitVR(),
() => this.handleResume()
);
this._statusScreen.initialize(this._camera); 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 * Update physics based on combined input from all input sources
*/ */
@ -302,16 +416,22 @@ export class Ship {
return; return;
} }
// Check if ship is still in the landing zone by checking distance // Check if ship mesh intersects with landing zone mesh
// 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)
const wasInZone = this._isInLandingZone; 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) { if (this._isInLandingZone && !wasInZone) {
debugLog("Ship entered landing zone - resupply active"); debugLog("Ship entered landing zone - resupply active");
} else if (!this._isInLandingZone && wasInZone) { } else if (!this._isInLandingZone && wasInZone) {
@ -372,12 +492,11 @@ export class Ship {
public setLandingZone(landingAggregate: PhysicsAggregate): void { public setLandingZone(landingAggregate: PhysicsAggregate): void {
this._landingAggregate = landingAggregate; 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) => { landingAggregate.body.getCollisionObservable().add((collisionEvent) => {
// Check if the collision is with our ship // Check if the collision is with our ship
if (collisionEvent.collider === this._ship.physicsBody) { if (collisionEvent.collider === this._ship.physicsBody) {
this._isInLandingZone = true; debugLog("Physics trigger fired for landing zone");
debugLog("Ship entered landing zone - resupply active");
} }
}); });
} }

View File

@ -1,5 +1,6 @@
import { import {
AdvancedDynamicTexture, AdvancedDynamicTexture,
Button,
Control, Control,
Rectangle, Rectangle,
StackPanel, StackPanel,
@ -14,6 +15,7 @@ import {
Vector3 Vector3
} from "@babylonjs/core"; } from "@babylonjs/core";
import { GameStats } from "./gameStats"; import { GameStats } from "./gameStats";
import { DefaultScene } from "./defaultScene";
/** /**
* Status screen that displays game statistics * Status screen that displays game statistics
@ -35,9 +37,25 @@ export class StatusScreen {
private _accuracyText: TextBlock; private _accuracyText: TextBlock;
private _fuelConsumedText: 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._scene = scene;
this._gameStats = gameStats; 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%"); this._fuelConsumedText = this.createStatText("Fuel Consumed: 0%");
mainPanel.addControl(this._fuelConsumedText); 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); this._texture.addControl(mainPanel);
// Initially hide the screen // 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) { if (!this._screenMesh) {
return; 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 // Update statistics before showing
this.updateStatistics(); this.updateStatistics();
@ -189,6 +318,9 @@ export class StatusScreen {
return; return;
} }
// Disable pointer selection when hiding
this.disablePointerSelection();
this._screenMesh.setEnabled(false); this._screenMesh.setEnabled(false);
this._isVisible = false; this._isVisible = false;
} }