space-game/src/ui/hud/statusScreen.ts
Michael Mainguy 5e67b796ba Add ESLint and refactor leaderboard to join with users table
- Add ESLint with typescript-eslint for unused code detection
- Fix 33 unused variable/import warnings across codebase
- Remove player_name from leaderboard insert (normalized design)
- Add ensureUserProfile() to upsert user display_name to users table
- Update leaderboard queries to join with users(display_name)
- Add getDisplayName() helper for leaderboard entries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 03:52:03 -06:00

657 lines
24 KiB
TypeScript

import {
AdvancedDynamicTexture,
Button,
Control,
Rectangle,
StackPanel,
TextBlock
} from "@babylonjs/gui";
import {
Camera,
Mesh,
MeshBuilder,
Scene,
StandardMaterial,
Vector3
} from "@babylonjs/core";
import { GameStats } from "../../game/gameStats";
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";
import { formatStars } from "../../game/scoreCalculator";
import { GameResultsService } from "../../services/gameResultsService";
import debugLog from "../../core/debug";
/**
* 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: Camera | null = null;
private _parTime: number = 120; // Default par time in seconds
// Text blocks for statistics
private _gameTimeText: TextBlock;
private _asteroidsText: TextBlock;
private _hullDamageText: TextBlock;
private _shotsFiredText: TextBlock;
private _accuracyText: TextBlock;
private _fuelConsumedText: TextBlock;
// Text blocks for score display
private _finalScoreText: TextBlock;
private _scoreBreakdownText: TextBlock;
private _starRatingText: TextBlock;
// Buttons
private _replayButton: Button;
private _exitButton: Button;
private _resumeButton: Button;
private _nextLevelButton: Button;
private _shareButton: Button | null = null;
// Callbacks
private _onReplayCallback: (() => void) | null = null;
private _onExitCallback: (() => void) | null = null;
private _onResumeCallback: (() => void) | null = null;
private _onNextLevelCallback: (() => void) | null = null;
// Track whether game has ended
private _isGameEnded: boolean = false;
// Track current level info for progression and results
private _currentLevelName: string | null = null;
private _currentLevelId: string | null = null;
private _totalAsteroids: number = 0;
// Track if result has been recorded (prevent duplicates)
private _resultRecorded: boolean = false;
constructor(scene: Scene, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void, onNextLevel?: () => void) {
this._scene = scene;
this._gameStats = gameStats;
this._onReplayCallback = onReplay || null;
this._onExitCallback = onExit || null;
this._onResumeCallback = onResume || null;
this._onNextLevelCallback = onNextLevel || null;
}
/**
* Initialize the status screen mesh and UI
*/
public initialize(): void {
this._camera = DefaultScene.XR.baseExperience.camera;
// Create a plane mesh for the status screen
this._screenMesh = MeshBuilder.CreatePlane(
"statusScreen",
{ width: 1.5, height: 2.25 },
this._scene
);
// Parent to camera for automatic following
this._screenMesh.parent = this._camera;
this._screenMesh.position = new Vector3(0, 0, 2); // 2 meters forward in local space
//this._screenMesh.rotation.y = Math.PI; // Face backward (toward user)
this._screenMesh.renderingGroupId = 3; // Always render on top
this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection
// Create material
const material = new StandardMaterial("statusScreenMaterial", this._scene);
this._screenMesh.material = material;
// Create AdvancedDynamicTexture
this._texture = AdvancedDynamicTexture.CreateForMesh(
this._screenMesh,
1024,
1536
);
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);
// Add spacing before score section
const spacer2 = this.createSpacer(40);
mainPanel.addControl(spacer2);
// Score section divider
const scoreDivider = new Rectangle("scoreDivider");
scoreDivider.height = "2px";
scoreDivider.width = "700px";
scoreDivider.background = "#00ff88";
scoreDivider.thickness = 0;
mainPanel.addControl(scoreDivider);
const spacer2b = this.createSpacer(30);
mainPanel.addControl(spacer2b);
// Final score display
const scoreTitle = this.createTitleText("FINAL SCORE");
scoreTitle.fontSize = "50px";
scoreTitle.height = "70px";
mainPanel.addControl(scoreTitle);
this._finalScoreText = new TextBlock();
this._finalScoreText.text = "0";
this._finalScoreText.color = "#FFD700"; // Gold color
this._finalScoreText.fontSize = "80px";
this._finalScoreText.height = "100px";
this._finalScoreText.fontWeight = "bold";
this._finalScoreText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
mainPanel.addControl(this._finalScoreText);
// Score breakdown
this._scoreBreakdownText = new TextBlock();
this._scoreBreakdownText.text = "";
this._scoreBreakdownText.color = "#aaaaaa";
this._scoreBreakdownText.fontSize = "20px";
this._scoreBreakdownText.height = "120px";
this._scoreBreakdownText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
this._scoreBreakdownText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
this._scoreBreakdownText.textWrapping = true;
mainPanel.addControl(this._scoreBreakdownText);
// Star ratings
this._starRatingText = new TextBlock();
this._starRatingText.text = "";
this._starRatingText.color = "#FFD700";
this._starRatingText.fontSize = "40px";
this._starRatingText.height = "100px";
this._starRatingText.fontWeight = "bold";
this._starRatingText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
this._starRatingText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
this._starRatingText.textWrapping = true;
mainPanel.addControl(this._starRatingText);
// Add spacing before buttons
const spacer3 = this.createSpacer(40);
mainPanel.addControl(spacer3);
// 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 Next Level button (only shown when game has ended and there's a next level)
/*this._nextLevelButton = Button.CreateSimpleButton("nextLevelButton", "NEXT LEVEL");
this._nextLevelButton.width = "300px";
this._nextLevelButton.height = "60px";
this._nextLevelButton.color = "white";
this._nextLevelButton.background = "#0088ff";
this._nextLevelButton.cornerRadius = 10;
this._nextLevelButton.thickness = 0;
this._nextLevelButton.fontSize = "30px";
this._nextLevelButton.fontWeight = "bold";
this._nextLevelButton.onPointerClickObservable.add(() => {
if (this._onNextLevelCallback) {
this._onNextLevelCallback();
}
});
buttonBar.addControl(this._nextLevelButton);
// Create Replay button (only shown when game has ended)
this._replayButton = Button.CreateSimpleButton("replayButton", "REPLAY");
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");
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);
// Create share button bar (separate row for social sharing)
const shareBar = new StackPanel("shareBar");
shareBar.isVertical = false;
shareBar.height = "80px";
shareBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
shareBar.spacing = 20;
shareBar.paddingTop = "20px";
// Create Share button (only shown when user is authenticated with Facebook)
this._shareButton = Button.CreateSimpleButton("shareButton", "📱 SHARE ON FACEBOOK");
this._shareButton.width = "400px";
this._shareButton.height = "60px";
this._shareButton.color = "white";
this._shareButton.background = "#1877f2"; // Facebook blue
this._shareButton.cornerRadius = 10;
this._shareButton.thickness = 0;
this._shareButton.fontSize = "28px";
this._shareButton.fontWeight = "bold";
this._shareButton.isVisible = false; // Hidden by default, shown only for Facebook users
this._shareButton.onPointerClickObservable.add(() => {
this.handleShareClick();
});
shareBar.addControl(this._shareButton);
mainPanel.addControl(shareBar);
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();
}
}
/**
* Set the current level info for progression tracking and results
*/
public setCurrentLevel(levelId: string, levelName: string, totalAsteroids: number): void {
console.log('[StatusScreen] setCurrentLevel called:', { levelId, levelName, totalAsteroids });
this._currentLevelId = levelId;
this._currentLevelName = levelName;
this._totalAsteroids = totalAsteroids;
}
/**
* Set the par time for score calculation
* @param parTime - Expected completion time in seconds
*/
public setParTime(parTime: number): void {
this._parTime = parTime;
}
/**
* Show the status screen
* @param isGameEnded - true if game has ended (death/stranded/victory), false if manually paused
* @param victory - true if the level was completed successfully
* @param endReason - specific reason for game end ('victory' | 'death' | 'stranded')
*/
public show(isGameEnded: boolean = false, victory: boolean = false, endReason?: 'victory' | 'death' | 'stranded'): void {
if (!this._screenMesh) {
return;
}
// Store game ended state
this._isGameEnded = isGameEnded;
// Mark level as complete if victory and we have a level name
const progression = ProgressionManager.getInstance();
if (victory && this._currentLevelName) {
const stats = this._gameStats.getStats();
const gameTimeSeconds = this.parseGameTime(stats.gameTime);
progression.markLevelComplete(this._currentLevelName, {
completionTime: gameTimeSeconds,
accuracy: stats.accuracy // Already a number from getAccuracy()
});
}
// Record game result when game ends (not on manual pause)
if (isGameEnded && endReason && !this._resultRecorded) {
this.recordGameResult(endReason);
this._resultRecorded = true;
}
// Determine if there's a next level
const nextLevel = progression.getNextLevel();
const hasNextLevel = nextLevel !== null;
// Show/hide appropriate buttons based on whether game has ended
if (this._resumeButton) {
this._resumeButton.isVisible = !isGameEnded;
}
if (this._replayButton) {
this._replayButton.isVisible = isGameEnded;
}
if (this._nextLevelButton) {
// Only show Next Level if game ended in victory and there's a next level
this._nextLevelButton.isVisible = isGameEnded && victory && hasNextLevel;
}
// Show share button only if game ended in victory and user is authenticated with Facebook
if (this._shareButton) {
const authService = AuthService.getInstance();
const isFacebookUser = authService.isAuthenticatedWithFacebook();
this._shareButton.isVisible = isGameEnded && victory && isFacebookUser;
// Initialize Facebook SDK if needed
if (this._shareButton.isVisible) {
const fbShare = FacebookShare.getInstance();
fbShare.initialize().catch(error => {
console.error('Failed to initialize Facebook SDK:', error);
});
}
}
// Disable ship controls and enable pointer selection via InputControlManager
const inputManager = InputControlManager.getInstance();
inputManager.disableShipControls("StatusScreen");
// Update statistics before showing
this.updateStatistics();
// Simply enable the mesh - position/rotation handled by parenting
this._screenMesh.setEnabled(true);
this._isVisible = true;
}
/**
* Parse game time string (MM:SS) to seconds
*/
private parseGameTime(timeString: string): number {
const parts = timeString.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
return minutes * 60 + seconds;
}
return 0;
}
/**
* Hide the status screen
*/
public hide(): void {
if (!this._screenMesh) {
return;
}
// Re-enable ship controls and disable pointer selection via InputControlManager
const inputManager = InputControlManager.getInstance();
inputManager.enableShipControls("StatusScreen");
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}%`;
// Calculate and display score
const scoreCalc = this._gameStats.calculateFinalScore(this._parTime);
// Update final score
this._finalScoreText.text = scoreCalc.finalScore.toLocaleString();
// Update score breakdown
this._scoreBreakdownText.text =
`Time: ${scoreCalc.timeMultiplier.toFixed(2)}x | ` +
`Accuracy: ${scoreCalc.accuracyMultiplier.toFixed(2)}x\n` +
`Fuel: ${scoreCalc.fuelMultiplier.toFixed(2)}x | ` +
`Hull: ${scoreCalc.hullMultiplier.toFixed(2)}x`;
// Update star ratings with Unicode stars and colors
const timeStars = formatStars(scoreCalc.stars.time);
const accStars = formatStars(scoreCalc.stars.accuracy);
const fuelStars = formatStars(scoreCalc.stars.fuel);
const hullStars = formatStars(scoreCalc.stars.hull);
this._starRatingText.text =
`${timeStars} ${accStars} ${fuelStars} ${hullStars}\n` +
`Time Acc Fuel Hull\n` +
`${scoreCalc.stars.total}/12 Stars`;
}
/**
* Check if status screen is visible
*/
public get isVisible(): boolean {
return this._isVisible;
}
/**
* Handle Facebook share button click
*/
private async handleShareClick(): Promise<void> {
const stats = this._gameStats.getStats();
const fbShare = FacebookShare.getInstance();
// Prepare share data
const shareData: ShareData = {
levelName: this._currentLevelName || 'Unknown Level',
gameTime: stats.gameTime,
asteroidsDestroyed: stats.asteroidsDestroyed,
accuracy: stats.accuracy,
completed: true
};
// Try to share via Facebook SDK
const success = await fbShare.shareResults(shareData);
if (!success) {
// Fallback to Web Share API or copy to clipboard
const webShareSuccess = await fbShare.shareWithWebAPI(shareData);
if (!webShareSuccess) {
// Final fallback - copy to clipboard
const copied = await fbShare.copyToClipboard(shareData);
if (copied) {
// Show notification (you could add a toast notification here)
console.log('Results copied to clipboard!');
// Update button text temporarily to show feedback
if (this._shareButton) {
const originalText = this._shareButton.textBlock?.text;
if (this._shareButton.textBlock) {
this._shareButton.textBlock.text = "✓ COPIED TO CLIPBOARD";
}
setTimeout(() => {
if (this._shareButton?.textBlock && originalText) {
this._shareButton.textBlock.text = originalText;
}
}, 2000);
}
}
}
} else {
// Success! Show feedback
if (this._shareButton) {
const originalText = this._shareButton.textBlock?.text;
const originalColor = this._shareButton.background;
if (this._shareButton.textBlock) {
this._shareButton.textBlock.text = "✓ SHARED!";
}
this._shareButton.background = "#00ff88";
setTimeout(() => {
if (this._shareButton?.textBlock && originalText) {
this._shareButton.textBlock.text = originalText;
this._shareButton.background = originalColor;
}
}, 2000);
}
}
}
/**
* Record game result to the results service
*/
private recordGameResult(endReason: 'victory' | 'death' | 'stranded'): void {
console.log('[StatusScreen] recordGameResult called with endReason:', endReason);
console.log('[StatusScreen] Level info:', {
levelId: this._currentLevelId,
levelName: this._currentLevelName,
totalAsteroids: this._totalAsteroids,
parTime: this._parTime
});
// Only record if we have level info
if (!this._currentLevelId || !this._currentLevelName) {
console.warn('[StatusScreen] Cannot record result - missing level info');
debugLog('[StatusScreen] Cannot record result - missing level info');
return;
}
try {
const result = GameResultsService.buildResult(
this._currentLevelId,
this._currentLevelName,
this._gameStats,
this._totalAsteroids,
endReason,
this._parTime
);
console.log('[StatusScreen] Built result:', result);
const service = GameResultsService.getInstance();
service.saveResult(result);
console.log('[StatusScreen] Game result saved successfully');
debugLog('[StatusScreen] Game result recorded:', result.id, result.finalScore, result.endReason);
} catch (error) {
console.error('[StatusScreen] Failed to record game result:', error);
debugLog('[StatusScreen] Failed to record game result:', error);
}
}
/**
* Dispose of status screen resources
*/
public dispose(): void {
if (this._texture) {
this._texture.dispose();
}
if (this._screenMesh) {
this._screenMesh.dispose();
}
}
}