Add status screen with game statistics display
Some checks failed
Build / build (push) Failing after 20s

Implemented a toggleable status screen that displays game statistics, activated by pressing the X button on the left VR controller.

**New File: src/gameStats.ts**
- GameStats class to track game statistics
- Tracks: game time, asteroids destroyed, hull damage taken, shots fired, shots hit, fuel consumed
- Methods: startTimer(), recordAsteroidDestroyed(), recordHullDamage(), recordShotFired(), recordShotHit(), recordFuelConsumed()
- Calculated stats: getGameTime(), getFormattedGameTime() (MM:SS), getAccuracy() (percentage)
- getStats() returns formatted statistics object
- reset() method to reset all statistics

**New File: src/statusScreen.ts**
- StatusScreen class for visual display of game statistics
- Creates a 1.5x1.0 meter plane mesh with AdvancedDynamicTexture (1024x768)
- Dark blue background (#1a1a2e) with green title (#00ff88)
- Displays 6 statistics:
  - Game Time (MM:SS format)
  - Asteroids Destroyed (count)
  - Hull Damage Taken (percentage)
  - Shots Fired (count)
  - Accuracy (percentage)
  - Fuel Consumed (percentage)
- toggle() method to show/hide screen
- Positions 2 meters in front of camera when shown
- Automatically faces camera with proper orientation
- updateStatistics() refreshes displayed values from GameStats

**Modified: src/controllerInput.ts**
- Added _onStatusScreenToggleObservable for X button events
- Added onStatusScreenToggleObservable getter
- Added X button handler in handleControllerEvent()
- Checks for "x-button" on left controller only
- Fires observable on button press (not release)

**Modified: src/ship.ts**
- Added StatusScreen and GameStats imports
- Added _statusScreen and _gameStats properties
- Initialize GameStats in constructor
- Initialize StatusScreen with camera reference after camera creation
- Wired up controller observable to toggle status screen
- Added statusScreen cleanup in dispose() method

Features:
- Toggle display with X button on left controller
- Floats 2 meters in front of user's face
- Always faces the camera
- Clean, readable statistics layout
- Currently displays placeholder data (statistics tracking integration pending)

🤖 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-07 15:46:58 -06:00
parent 827dd2d359
commit 406cebcd96
4 changed files with 375 additions and 0 deletions

View File

@ -48,6 +48,7 @@ export class ControllerInput {
private _onShootObservable: Observable<void> = new Observable<void>(); private _onShootObservable: Observable<void> = new Observable<void>();
private _onCameraAdjustObservable: Observable<CameraAdjustment> = private _onCameraAdjustObservable: Observable<CameraAdjustment> =
new Observable<CameraAdjustment>(); new Observable<CameraAdjustment>();
private _onStatusScreenToggleObservable: Observable<void> = new Observable<void>();
constructor() { constructor() {
this._controllerObservable.add(this.handleControllerEvent.bind(this)); this._controllerObservable.add(this.handleControllerEvent.bind(this));
@ -67,6 +68,13 @@ export class ControllerInput {
return this._onCameraAdjustObservable; return this._onCameraAdjustObservable;
} }
/**
* Get observable that fires when X button is pressed on left controller
*/
public get onStatusScreenToggleObservable(): Observable<void> {
return this._onStatusScreenToggleObservable;
}
/** /**
* Get current input state (stick positions) * Get current input state (stick positions)
*/ */
@ -225,6 +233,12 @@ export class ControllerInput {
direction: "up", direction: "up",
}); });
} }
if (controllerEvent.component.id === "x-button" && controllerEvent.hand === "left") {
// Only trigger on button press, not release
if (controllerEvent.pressed) {
this._onStatusScreenToggleObservable.notifyObservers();
}
}
console.log(controllerEvent); console.log(controllerEvent);
} }
} }

109
src/gameStats.ts Normal file
View File

@ -0,0 +1,109 @@
/**
* Tracks game statistics for display on status screen
*/
export class GameStats {
private _gameStartTime: number = 0;
private _asteroidsDestroyed: number = 0;
private _hullDamageTaken: number = 0;
private _shotsFired: number = 0;
private _shotsHit: number = 0;
private _fuelConsumed: number = 0;
/**
* Start the game timer
*/
public startTimer(): void {
this._gameStartTime = Date.now();
}
/**
* Get elapsed game time in seconds
*/
public getGameTime(): number {
if (this._gameStartTime === 0) {
return 0;
}
return Math.floor((Date.now() - this._gameStartTime) / 1000);
}
/**
* Get formatted game time as MM:SS
*/
public getFormattedGameTime(): string {
const seconds = this.getGameTime();
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
/**
* Increment asteroids destroyed count
*/
public recordAsteroidDestroyed(): void {
this._asteroidsDestroyed++;
}
/**
* Record hull damage taken
*/
public recordHullDamage(amount: number): void {
this._hullDamageTaken += amount;
}
/**
* Record a shot fired
*/
public recordShotFired(): void {
this._shotsFired++;
}
/**
* Record a shot that hit a target
*/
public recordShotHit(): void {
this._shotsHit++;
}
/**
* Record fuel consumed
*/
public recordFuelConsumed(amount: number): void {
this._fuelConsumed += amount;
}
/**
* Get accuracy percentage
*/
public getAccuracy(): number {
if (this._shotsFired === 0) {
return 0;
}
return Math.round((this._shotsHit / this._shotsFired) * 100);
}
/**
* Get all statistics
*/
public getStats() {
return {
gameTime: this.getFormattedGameTime(),
asteroidsDestroyed: this._asteroidsDestroyed,
hullDamageTaken: Math.round(this._hullDamageTaken * 100),
shotsFired: this._shotsFired,
accuracy: this.getAccuracy(),
fuelConsumed: Math.round(this._fuelConsumed * 100)
};
}
/**
* Reset all statistics
*/
public reset(): void {
this._gameStartTime = Date.now();
this._asteroidsDestroyed = 0;
this._hullDamageTaken = 0;
this._shotsFired = 0;
this._shotsHit = 0;
this._fuelConsumed = 0;
}
}

View File

@ -24,6 +24,8 @@ import { ControllerInput } from "./controllerInput";
import { ShipPhysics } from "./shipPhysics"; import { ShipPhysics } from "./shipPhysics";
import { ShipAudio } from "./shipAudio"; import { ShipAudio } from "./shipAudio";
import { WeaponSystem } from "./weaponSystem"; import { WeaponSystem } from "./weaponSystem";
import { StatusScreen } from "./statusScreen";
import { GameStats } from "./gameStats";
export class Ship { export class Ship {
private _ship: TransformNode; private _ship: TransformNode;
@ -38,6 +40,8 @@ export class Ship {
private _physics: ShipPhysics; private _physics: ShipPhysics;
private _audio: ShipAudio; private _audio: ShipAudio;
private _weapons: WeaponSystem; private _weapons: WeaponSystem;
private _statusScreen: StatusScreen;
private _gameStats: GameStats;
// Frame counter for physics updates // Frame counter for physics updates
private _frameCount: number = 0; private _frameCount: number = 0;
@ -74,6 +78,7 @@ export class Ship {
public async initialize() { public async initialize() {
this._scoreboard = new Scoreboard(); this._scoreboard = new Scoreboard();
this._gameStats = new GameStats();
this._ship = new TransformNode("shipBase", DefaultScene.MainScene); this._ship = new TransformNode("shipBase", DefaultScene.MainScene);
const data = await loadAsset("ship.glb"); const data = await loadAsset("ship.glb");
this._ship = data.container.transformNodes[0]; this._ship = data.container.transformNodes[0];
@ -139,6 +144,13 @@ export class Ship {
this.handleShoot(); this.handleShoot();
}); });
// Wire up status screen toggle event
this._controllerInput.onStatusScreenToggleObservable.add(() => {
if (this._statusScreen) {
this._statusScreen.toggle();
}
});
// Wire up camera adjustment events // Wire up camera adjustment events
this._keyboardInput.onCameraChangeObservable.add((cameraKey) => { this._keyboardInput.onCameraChangeObservable.add((cameraKey) => {
if (cameraKey === 1) { if (cameraKey === 1) {
@ -192,6 +204,10 @@ export class Ship {
// Initialize scoreboard (it will retrieve and setup its own screen mesh) // Initialize scoreboard (it will retrieve and setup its own screen mesh)
this._scoreboard.initialize(); this._scoreboard.initialize();
// Initialize status screen
this._statusScreen = new StatusScreen(DefaultScene.MainScene, this._gameStats);
this._statusScreen.initialize(this._camera);
} }
/** /**
@ -367,5 +383,9 @@ export class Ship {
if (this._weapons) { if (this._weapons) {
this._weapons.dispose(); this._weapons.dispose();
} }
if (this._statusScreen) {
this._statusScreen.dispose();
}
} }
} }

232
src/statusScreen.ts Normal file
View File

@ -0,0 +1,232 @@
import {
AdvancedDynamicTexture,
Control,
Rectangle,
StackPanel,
TextBlock
} from "@babylonjs/gui";
import {
Mesh,
MeshBuilder,
Scene,
StandardMaterial,
TransformNode,
Vector3
} from "@babylonjs/core";
import { GameStats } from "./gameStats";
/**
* Status screen that displays game statistics
* Floats in front of the user and can be toggled on/off
*/
export class StatusScreen {
private _scene: Scene;
private _gameStats: GameStats;
private _screenMesh: Mesh | null = null;
private _texture: AdvancedDynamicTexture | null = null;
private _isVisible: boolean = false;
private _camera: TransformNode | null = null;
// Text blocks for statistics
private _gameTimeText: TextBlock;
private _asteroidsText: TextBlock;
private _hullDamageText: TextBlock;
private _shotsFiredText: TextBlock;
private _accuracyText: TextBlock;
private _fuelConsumedText: TextBlock;
constructor(scene: Scene, gameStats: GameStats) {
this._scene = scene;
this._gameStats = gameStats;
}
/**
* Initialize the status screen mesh and UI
*/
public initialize(camera: TransformNode): void {
this._camera = camera;
// Create a plane mesh for the status screen
this._screenMesh = MeshBuilder.CreatePlane(
"statusScreen",
{ width: 1.5, height: 1.0 },
this._scene
);
// Create material
const material = new StandardMaterial("statusScreenMaterial", this._scene);
this._screenMesh.material = material;
// Create AdvancedDynamicTexture
this._texture = AdvancedDynamicTexture.CreateForMesh(
this._screenMesh,
1024,
768
);
this._texture.background = "#1a1a2e";
// Create main container
const mainPanel = new StackPanel("mainPanel");
mainPanel.width = "100%";
mainPanel.height = "100%";
mainPanel.isVertical = true;
mainPanel.paddingTop = "40px";
mainPanel.paddingBottom = "40px";
mainPanel.paddingLeft = "60px";
mainPanel.paddingRight = "60px";
// Title
const title = this.createTitleText("GAME STATISTICS");
mainPanel.addControl(title);
// Add spacing
const spacer1 = this.createSpacer(40);
mainPanel.addControl(spacer1);
// Create statistics display
this._gameTimeText = this.createStatText("Game Time: 00:00");
mainPanel.addControl(this._gameTimeText);
this._asteroidsText = this.createStatText("Asteroids Destroyed: 0");
mainPanel.addControl(this._asteroidsText);
this._hullDamageText = this.createStatText("Hull Damage Taken: 0%");
mainPanel.addControl(this._hullDamageText);
this._shotsFiredText = this.createStatText("Shots Fired: 0");
mainPanel.addControl(this._shotsFiredText);
this._accuracyText = this.createStatText("Accuracy: 0%");
mainPanel.addControl(this._accuracyText);
this._fuelConsumedText = this.createStatText("Fuel Consumed: 0%");
mainPanel.addControl(this._fuelConsumedText);
this._texture.addControl(mainPanel);
// Initially hide the screen
this._screenMesh.setEnabled(false);
this._isVisible = false;
}
/**
* Create title text block
*/
private createTitleText(text: string): TextBlock {
const textBlock = new TextBlock();
textBlock.text = text;
textBlock.color = "#00ff88";
textBlock.fontSize = "80px";
textBlock.height = "100px";
textBlock.fontWeight = "bold";
textBlock.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
return textBlock;
}
/**
* Create stat text block
*/
private createStatText(text: string): TextBlock {
const textBlock = new TextBlock();
textBlock.text = text;
textBlock.color = "#ffffff";
textBlock.fontSize = "50px";
textBlock.height = "70px";
textBlock.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
textBlock.paddingTop = "10px";
textBlock.paddingBottom = "10px";
return textBlock;
}
/**
* Create spacer for layout
*/
private createSpacer(height: number): Rectangle {
const spacer = new Rectangle();
spacer.height = `${height}px`;
spacer.thickness = 0;
return spacer;
}
/**
* Toggle visibility of status screen
*/
public toggle(): void {
if (this._isVisible) {
this.hide();
} else {
this.show();
}
}
/**
* Show the status screen
*/
public show(): void {
if (!this._screenMesh || !this._camera) {
return;
}
// Update statistics before showing
this.updateStatistics();
// Position screen 2 meters in front of camera
const cameraForward = this._camera.forward;
const offset = cameraForward.scale(2.0);
this._screenMesh.position = this._camera.position.add(offset);
// Make screen face the camera
this._screenMesh.lookAt(this._camera.position);
// Rotate 180 degrees to face the right way
this._screenMesh.rotate(Vector3.Up(), Math.PI);
this._screenMesh.setEnabled(true);
this._isVisible = true;
}
/**
* Hide the status screen
*/
public hide(): void {
if (!this._screenMesh) {
return;
}
this._screenMesh.setEnabled(false);
this._isVisible = false;
}
/**
* Update displayed statistics
*/
public updateStatistics(): void {
const stats = this._gameStats.getStats();
this._gameTimeText.text = `Game Time: ${stats.gameTime}`;
this._asteroidsText.text = `Asteroids Destroyed: ${stats.asteroidsDestroyed}`;
this._hullDamageText.text = `Hull Damage Taken: ${stats.hullDamageTaken}%`;
this._shotsFiredText.text = `Shots Fired: ${stats.shotsFired}`;
this._accuracyText.text = `Accuracy: ${stats.accuracy}%`;
this._fuelConsumedText.text = `Fuel Consumed: ${stats.fuelConsumed}%`;
}
/**
* Check if status screen is visible
*/
public get isVisible(): boolean {
return this._isVisible;
}
/**
* Dispose of status screen resources
*/
public dispose(): void {
if (this._texture) {
this._texture.dispose();
}
if (this._screenMesh) {
this._screenMesh.dispose();
}
}
}