From 749cc1821111c2dd9b91d32dadca90342bb6867e Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 1 Dec 2025 16:39:27 -0600 Subject: [PATCH] Refactor scoring system to additive model starting at 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace multiplier-based scoring with additive system - Score builds from asteroid destruction based on size and timing - Small asteroids (<10 scale): 1000 pts, Medium (10-20): 500 pts, Large (>20): 250 pts - Timing multiplier: 3x in first 1/3 of par time, 2x in middle, 1x in last third - End-game bonuses only applied at game end (hull, fuel, accuracy) - Add scale property to ScoreEvent for point calculation - Update status screen to show "CURRENT SCORE" during play, "FINAL SCORE" at end - Refactor star ratings display into individual columns - Fix button clipping on hover with clipChildren = false - Add reusable button hover effects utility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/environment/asteroids/rockFactory.ts | 3 +- src/game/gameStats.ts | 44 ++-- src/game/scoreCalculator.ts | 256 +++++++++-------------- src/services/gameResultsService.ts | 2 +- src/ship/ship.ts | 12 +- src/ship/weaponSystem.ts | 6 +- src/ui/hud/missionBrief.ts | 11 +- src/ui/hud/scoreboard.ts | 3 +- src/ui/hud/statusScreen.ts | 121 +++++++---- src/ui/screens/preloader.ts | 9 +- src/ui/utils/buttonEffects.ts | 29 +++ 11 files changed, 270 insertions(+), 226 deletions(-) create mode 100644 src/ui/utils/buttonEffects.ts diff --git a/src/environment/asteroids/rockFactory.ts b/src/environment/asteroids/rockFactory.ts index 1cdba62..b019f52 100644 --- a/src/environment/asteroids/rockFactory.ts +++ b/src/environment/asteroids/rockFactory.ts @@ -175,10 +175,11 @@ export class RockFactory { if (eventData.type == 'COLLISION_STARTED') { if ( eventData.collidedAgainst.transformNode.id == 'ammo') { log.debug('[RockFactory] ASTEROID HIT! Triggering explosion...'); - score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed"}); // Get the asteroid mesh before disposing const asteroidMesh = eventData.collider.transformNode as AbstractMesh; + const asteroidScale = asteroidMesh.scaling.x; + score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed", scale: asteroidScale}); log.debug('[RockFactory] Asteroid mesh to explode:', { name: asteroidMesh.name, id: asteroidMesh.id, diff --git a/src/game/gameStats.ts b/src/game/gameStats.ts index e3c0179..60ed940 100644 --- a/src/game/gameStats.ts +++ b/src/game/gameStats.ts @@ -1,6 +1,6 @@ import { getAnalytics } from "../analytics"; import log from "../core/logger"; -import { calculateScore, ScoreCalculation } from "./scoreCalculator"; +import { calculateAsteroidPoints, calculateFinalScore, ScoreCalculation } from "./scoreCalculator"; /** * Tracks game statistics for display on status screen @@ -13,6 +13,8 @@ export class GameStats { private _shotsHit: number = 0; private _fuelConsumed: number = 0; private _performanceTimer: number | null = null; + private _runningScore: number = 0; + private _parTime: number = 120; /** * Start the game timer and performance tracking @@ -89,10 +91,27 @@ export class GameStats { } /** - * Increment asteroids destroyed count + * Set the par time for score calculation */ - public recordAsteroidDestroyed(): void { + public setParTime(parTime: number): void { + this._parTime = parTime; + } + + /** + * Record asteroid destroyed and calculate points + * @param scale - Asteroid scale (size) + */ + public recordAsteroidDestroyed(scale: number = 1): void { this._asteroidsDestroyed++; + const points = calculateAsteroidPoints(scale, this.getGameTime(), this._parTime); + this._runningScore += points; + } + + /** + * Get the running score from asteroid destruction + */ + public getRunningScore(): number { + return this._runningScore; } /** @@ -181,6 +200,7 @@ export class GameStats { this._shotsFired = 0; this._shotsHit = 0; this._fuelConsumed = 0; + this._runningScore = 0; // Restart performance tracking this.startPerformanceTracking(); @@ -188,22 +208,20 @@ export class GameStats { /** * Calculate final score based on current statistics - * - * @param parTime - Expected completion time in seconds (default: 120) - * @returns Complete score calculation with multipliers and star ratings + * @param includeEndGameBonuses - Whether to include end-game bonuses (only at game end) + * @returns Complete score calculation with bonuses and star ratings */ - public calculateFinalScore(parTime: number = 120): ScoreCalculation { - const gameTimeSeconds = this.getGameTime(); + public getFinalScore(includeEndGameBonuses: boolean = true): ScoreCalculation { const accuracy = this.getAccuracy(); const fuelConsumed = this._fuelConsumed * 100; // Convert to percentage const hullDamage = this._hullDamageTaken * 100; // Convert to percentage - return calculateScore( - gameTimeSeconds, - accuracy, - fuelConsumed, + return calculateFinalScore( + this._runningScore, hullDamage, - parTime + fuelConsumed, + accuracy, + includeEndGameBonuses ); } diff --git a/src/game/scoreCalculator.ts b/src/game/scoreCalculator.ts index a8489ed..c5dcd15 100644 --- a/src/game/scoreCalculator.ts +++ b/src/game/scoreCalculator.ts @@ -1,14 +1,18 @@ /** * Score calculation system for space shooter game - * Uses linear-clamped multipliers that never go negative - * Rewards speed, accuracy, fuel efficiency, and hull integrity + * Additive scoring: starts at 0, builds through asteroid destruction and end-game bonuses */ +// Bonus constants +const MAX_HULL_BONUS = 5000; +const MAX_FUEL_BONUS = 5000; +const MAX_ACCURACY_BONUS = 10000; + /** * Star rating levels (0-3 stars per category) */ interface StarRatings { - time: number; // 0-3 stars based on completion time + asteroids: number; // 0-3 stars based on asteroid destruction timing accuracy: number; // 0-3 stars based on shot accuracy fuel: number; // 0-3 stars based on fuel efficiency hull: number; // 0-3 stars based on hull integrity @@ -16,221 +20,154 @@ interface StarRatings { } /** - * Debug information for score calculation + * End-game bonus breakdown */ -interface ScoreDebugInfo { - rawFuelConsumed: number; // Actual fuel consumed (can be >100%) - rawHullDamage: number; // Actual hull damage (can be >100%) - fuelEfficiency: number; // 0-100 display value (clamped) - hullIntegrity: number; // 0-100 display value (clamped) +export interface EndGameBonuses { + hull: number; + fuel: number; + accuracy: number; } /** * Complete score calculation result */ export interface ScoreCalculation { - baseScore: number; - timeMultiplier: number; - accuracyMultiplier: number; - fuelMultiplier: number; - hullMultiplier: number; - finalScore: number; + asteroidScore: number; // Points from destroying asteroids + bonuses: EndGameBonuses; // End-game bonuses + finalScore: number; // Total score stars: StarRatings; - debug: ScoreDebugInfo; } /** - * Configuration for score calculation + * Calculate points for destroying an asteroid + * @param scale - Asteroid scale (size) + * @param elapsedSeconds - Time elapsed since game start + * @param parTime - Expected level completion time + * @returns Points earned for this asteroid */ -interface ScoreConfig { - baseScore?: number; // Default: 10000 - minMultiplier?: number; // Minimum multiplier floor (default: 0.5) - maxTimeMultiplier?: number; // Maximum time bonus (default: 3.0) - minTimeMultiplier?: number; // Minimum time multiplier (default: 0.1) +export function calculateAsteroidPoints( + scale: number, + elapsedSeconds: number, + parTime: number +): number { + // Size points: smaller scale = more points + // Small (<10): 1000 pts, Medium (10-20): 500 pts, Large (>20): 250 pts + const sizePoints = scale < 10 ? 1000 : scale <= 20 ? 500 : 250; + + // Timing multiplier based on par time progress + const progress = elapsedSeconds / parTime; + const timingMultiplier = progress <= 0.333 ? 3 : progress <= 0.666 ? 2 : 1; + + return sizePoints * timingMultiplier; } /** - * Calculate final score based on performance metrics - * - * @param gameTimeSeconds - Total game time in seconds - * @param accuracy - Shot accuracy percentage (0-100) - * @param fuelConsumed - Fuel consumed percentage (0-∞, can exceed 100% with refuels) - * @param hullDamage - Hull damage percentage (0-∞, can exceed 100% with deaths/repairs) - * @param parTime - Expected completion time in seconds (default: 120) - * @param config - Optional scoring configuration - * @returns Complete score calculation with multipliers and star ratings + * Calculate end-game bonuses based on performance + * @param hullDamage - Total hull damage taken (0-300+%) + * @param fuelConsumed - Total fuel consumed (0-300+%) + * @param accuracy - Shot accuracy percentage (0-100%) + * @returns Bonus points for each category */ -export function calculateScore( - gameTimeSeconds: number, - accuracy: number, - fuelConsumed: number, +export function calculateEndGameBonuses( hullDamage: number, - parTime: number = 120, - config: ScoreConfig = {} + fuelConsumed: number, + accuracy: number +): EndGameBonuses { + return { + hull: Math.floor(MAX_HULL_BONUS * Math.max(0, 1 - hullDamage / 300)), + fuel: Math.floor(MAX_FUEL_BONUS * Math.max(0, 1 - fuelConsumed / 300)), + accuracy: Math.floor(MAX_ACCURACY_BONUS * Math.max(0, (accuracy - 1) / 99)) + }; +} + +/** + * Calculate final score with all bonuses + * @param asteroidScore - Running score from asteroid destruction + * @param hullDamage - Hull damage percentage (0-300+%) + * @param fuelConsumed - Fuel consumed percentage (0-300+%) + * @param accuracy - Shot accuracy percentage (0-100%) + * @param includeEndGameBonuses - Whether to include end-game bonuses (only at game end) + * @returns Complete score calculation + */ +export function calculateFinalScore( + asteroidScore: number, + hullDamage: number, + fuelConsumed: number, + accuracy: number, + includeEndGameBonuses: boolean = true ): ScoreCalculation { - const { - baseScore = 10000, - minMultiplier = 0.5, - maxTimeMultiplier = 3.0, - minTimeMultiplier = 0.1 - } = config; + const bonuses = includeEndGameBonuses + ? calculateEndGameBonuses(hullDamage, fuelConsumed, accuracy) + : { hull: 0, fuel: 0, accuracy: 0 }; + const finalScore = asteroidScore + bonuses.hull + bonuses.fuel + bonuses.accuracy; - // ============================================ - // TIME MULTIPLIER - // ============================================ - // Exponential decay from par time - // Faster than par = >1.0x, slower = <1.0x - // Clamped between minTimeMultiplier and maxTimeMultiplier - const timeRatio = gameTimeSeconds / parTime; - const timeMultiplier = Math.max( - minTimeMultiplier, - Math.min( - maxTimeMultiplier, - Math.exp(-timeRatio + 1) * 2 - ) - ); - - // ============================================ - // ACCURACY MULTIPLIER - // ============================================ - // Linear scaling: 0% = 1.0x, 100% = 2.0x - // Accuracy is always 0-100%, so no clamping needed - const accuracyMultiplier = 1.0 + (accuracy / 100); - - // ============================================ - // FUEL EFFICIENCY MULTIPLIER - // ============================================ - // Linear with floor for refueling scenarios - // 0% consumed = 2.0x (perfect) - // 50% consumed = 1.5x - // 100% consumed = 1.0x - // >100% consumed = minMultiplier floor (e.g., 0.5x) - const fuelEfficiencyScore = Math.max(0, 100 - fuelConsumed); - const fuelMultiplier = Math.max( - minMultiplier, - 1.0 + (fuelEfficiencyScore / 100) - ); - - // ============================================ - // HULL INTEGRITY MULTIPLIER - // ============================================ - // Linear with floor for death/repair scenarios - // 0% damage = 2.0x (perfect) - // 50% damage = 1.5x - // 100% damage = 1.0x - // >100% damage = minMultiplier floor (e.g., 0.5x) - const hullIntegrityScore = Math.max(0, 100 - hullDamage); - const hullMultiplier = Math.max( - minMultiplier, - 1.0 + (hullIntegrityScore / 100) - ); - - // ============================================ - // FINAL SCORE CALCULATION - // ============================================ - const finalScore = Math.floor( - baseScore * - timeMultiplier * - accuracyMultiplier * - fuelMultiplier * - hullMultiplier - ); - - // ============================================ - // STAR RATINGS - // ============================================ const stars: StarRatings = { - time: getTimeStars(gameTimeSeconds, parTime), + asteroids: getAsteroidStars(asteroidScore), accuracy: getAccuracyStars(accuracy), fuel: getFuelStars(fuelConsumed), hull: getHullStars(hullDamage), total: 0 }; - stars.total = stars.time + stars.accuracy + stars.fuel + stars.hull; - - // ============================================ - // DEBUG INFO - // ============================================ - const debug: ScoreDebugInfo = { - rawFuelConsumed: fuelConsumed, - rawHullDamage: hullDamage, - fuelEfficiency: Math.max(0, Math.min(100, 100 - fuelConsumed)), - hullIntegrity: Math.max(0, Math.min(100, 100 - hullDamage)) - }; + stars.total = stars.asteroids + stars.accuracy + stars.fuel + stars.hull; return { - baseScore, - timeMultiplier, - accuracyMultiplier, - fuelMultiplier, - hullMultiplier, + asteroidScore, + bonuses, finalScore, - stars, - debug + stars }; } /** - * Calculate time stars based on completion time vs par - * - * @param seconds - Completion time in seconds - * @param par - Par time in seconds - * @returns 0-3 stars + * Calculate asteroid stars based on score earned + * Note: This is a rough heuristic; actual thresholds may need tuning per level */ -function getTimeStars(seconds: number, par: number): number { - const ratio = seconds / par; - if (ratio <= 0.5) return 3; // Finished in half the par time - if (ratio <= 1.0) return 2; // Finished at or under par - if (ratio <= 1.5) return 1; // Finished within 150% of par - return 0; // Over 150% of par +function getAsteroidStars(asteroidScore: number): number { + // Assumes average ~20,000 pts for good performance + if (asteroidScore >= 25000) return 3; + if (asteroidScore >= 15000) return 2; + if (asteroidScore >= 8000) return 1; + return 0; } /** * Calculate accuracy stars based on hit percentage - * * @param accuracy - Shot accuracy percentage (0-100) * @returns 0-3 stars */ function getAccuracyStars(accuracy: number): number { - if (accuracy >= 75) return 3; // Excellent accuracy - if (accuracy >= 50) return 2; // Good accuracy - if (accuracy >= 25) return 1; // Fair accuracy - return 0; // Poor accuracy + if (accuracy >= 80) return 3; + if (accuracy >= 50) return 2; + if (accuracy >= 20) return 1; + return 0; } /** * Calculate fuel efficiency stars - * - * @param fuelConsumed - Fuel consumed percentage (0-∞) + * @param fuelConsumed - Fuel consumed percentage (0-300+%) * @returns 0-3 stars */ function getFuelStars(fuelConsumed: number): number { - // Stars only consider first 100% of fuel - // Refueling doesn't earn extra stars - if (fuelConsumed <= 30) return 3; // Used ≤30% fuel - if (fuelConsumed <= 60) return 2; // Used ≤60% fuel - if (fuelConsumed <= 80) return 1; // Used ≤80% fuel - return 0; // Used >80% fuel (including refuels) + if (fuelConsumed <= 50) return 3; + if (fuelConsumed <= 150) return 2; + if (fuelConsumed <= 250) return 1; + return 0; } /** * Calculate hull integrity stars - * - * @param hullDamage - Hull damage percentage (0-∞) + * @param hullDamage - Hull damage percentage (0-300+%) * @returns 0-3 stars */ function getHullStars(hullDamage: number): number { - // Stars only consider first 100% of damage - // Dying and respawning = 0 stars - if (hullDamage <= 10) return 3; // Took ≤10% damage - if (hullDamage <= 30) return 2; // Took ≤30% damage - if (hullDamage <= 60) return 1; // Took ≤60% damage - return 0; // Took >60% damage (including deaths) + if (hullDamage <= 30) return 3; + if (hullDamage <= 100) return 2; + if (hullDamage <= 200) return 1; + return 0; } /** * Get star rating color based on count - * * @param stars - Number of stars (0-3) * @returns Hex color code */ @@ -245,7 +182,6 @@ export function getStarColor(stars: number): string { /** * Format stars as Unicode string - * * @param earned - Number of stars earned (0-3) * @param total - Total possible stars (default: 3) * @returns Unicode star string (e.g., "★★☆") diff --git a/src/services/gameResultsService.ts b/src/services/gameResultsService.ts index 6a68470..36a304f 100644 --- a/src/services/gameResultsService.ts +++ b/src/services/gameResultsService.ts @@ -154,7 +154,7 @@ export class GameResultsService { // Get stats const stats = gameStats.getStats(); - const scoreCalc = gameStats.calculateFinalScore(parTime); + const scoreCalc = gameStats.getFinalScore(); return { id: crypto.randomUUID(), diff --git a/src/ship/ship.ts b/src/ship/ship.ts index a2b9b6f..7842214 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -418,17 +418,17 @@ export class Ship { this._scoreboard.initialize(); // Subscribe to score events to track asteroids destroyed - this._scoreboard.onScoreObservable.add(() => { - // Each score event represents an asteroid destroyed - this._gameStats.recordAsteroidDestroyed(); + this._scoreboard.onScoreObservable.add((event) => { + // Each score event represents an asteroid destroyed, pass scale for point calc + this._gameStats.recordAsteroidDestroyed(event.scale || 1); // Track asteroid destruction in analytics try { const analytics = getAnalytics(); analytics.track('asteroid_destroyed', { - weaponType: 'laser', // TODO: Get actual weapon type from event - distance: 0, // TODO: Calculate distance if available - asteroidSize: 0, // TODO: Get actual size if available + weaponType: 'laser', + distance: 0, + asteroidSize: event.scale || 0, remainingCount: this._scoreboard.remaining }, { sampleRate: 0.2 }); // Sample 20% of asteroid events to reduce data } catch (error) { diff --git a/src/ship/weaponSystem.ts b/src/ship/weaponSystem.ts index d000ee4..8911415 100644 --- a/src/ship/weaponSystem.ts +++ b/src/ship/weaponSystem.ts @@ -198,12 +198,14 @@ export class WeaponSystem { if (isAsteroid) { log.debug('[WeaponSystem] Asteroid hit! Triggering destruction...'); - // Update score + // Update score with asteroid scale for point calculation if (this._scoreObservable) { + const asteroidScale = hitMesh.scaling.x; this._scoreObservable.notifyObservers({ score: 1, remaining: -1, - message: "Asteroid Destroyed" + message: "Asteroid Destroyed", + scale: asteroidScale }); } diff --git a/src/ui/hud/missionBrief.ts b/src/ui/hud/missionBrief.ts index 2a0267e..a8174d9 100644 --- a/src/ui/hud/missionBrief.ts +++ b/src/ui/hud/missionBrief.ts @@ -12,6 +12,7 @@ import type { AudioEngineV2 } from "@babylonjs/core"; import log from '../../core/logger'; import { LevelConfig } from "../../levels/config/levelConfig"; import { CloudLevelEntry } from "../../services/cloudLevelService"; +import { addButtonHoverEffect } from "../utils/buttonEffects"; /** * Mission brief display for VR @@ -48,9 +49,9 @@ export class MissionBrief { } mesh.parent = ship; - mesh.position = new Vector3(0,1,2.8); + mesh.position = new Vector3(0,1.2,2); mesh.rotation = new Vector3(0, 0, 0); - mesh.renderingGroupId = 3; // Same as status screen for consistent rendering + //mesh.renderingGroupId = 3; // Same as status screen for consistent rendering mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection log.info('[MissionBrief] Mesh parented to ship at position:', mesh.position); log.info('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition()); @@ -71,7 +72,7 @@ export class MissionBrief { this._container.height = "600px"; this._container.thickness = 4; this._container.color = "#00ff00"; - this._container.background = "rgba(0, 0, 0, 0.95)"; + this._container.background = "rgba(0, 0, 0, 0.99)"; this._container.cornerRadius = 20; this._container.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; this._container.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; @@ -202,7 +203,7 @@ export class MissionBrief { // Spacer before button const spacer3 = new Rectangle("spacer3"); - spacer3.height = "40px"; + spacer3.height = "20px"; spacer3.thickness = 0; contentPanel.addControl(spacer3); @@ -217,6 +218,8 @@ export class MissionBrief { startButton.fontSize = "36px"; startButton.fontWeight = "bold"; startButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + addButtonHoverEffect(startButton); + startButton.onPointerClickObservable.add(() => { log.debug('[MissionBrief] START button clicked - dismissing mission brief'); this.hide(); diff --git a/src/ui/hud/scoreboard.ts b/src/ui/hud/scoreboard.ts index 236ae07..5528d0d 100644 --- a/src/ui/hud/scoreboard.ts +++ b/src/ui/hud/scoreboard.ts @@ -13,7 +13,8 @@ export type ScoreEvent = { score: number, message: string, remaining: number, - timeRemaining? : number + scale?: number, // Asteroid scale for point calculation + timeRemaining?: number } export class Scoreboard { private _score: number = 0; diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts index 434fc91..7f127c7 100644 --- a/src/ui/hud/statusScreen.ts +++ b/src/ui/hud/statusScreen.ts @@ -18,6 +18,7 @@ import { GameStats } from "../../game/gameStats"; import { DefaultScene } from "../../core/defaultScene"; import { ProgressionManager } from "../../game/progression"; import { AuthService } from "../../services/authService"; +import { addButtonHoverEffect } from "../utils/buttonEffects"; import { FacebookShare, ShareData } from "../../services/facebookShare"; import { InputControlManager } from "../../ship/input/inputControlManager"; import { formatStars } from "../../game/scoreCalculator"; @@ -46,9 +47,11 @@ export class StatusScreen { private _fuelConsumedText: TextBlock; // Text blocks for score display + private _scoreTitleText: TextBlock; private _finalScoreText: TextBlock; private _scoreBreakdownText: TextBlock; - private _starRatingText: TextBlock; + private _starsContainer: StackPanel; + private _totalStarsText: TextBlock; // Buttons private _replayButton: Button; @@ -97,8 +100,8 @@ export class StatusScreen { // Parent to ship for fixed cockpit position this._screenMesh.parent = this._shipNode; - this._screenMesh.position = new Vector3(0, 1, 2); // 2 meters forward in local space - this._screenMesh.renderingGroupId = 3; // Always render on top + this._screenMesh.position = new Vector3(0, 1.1, 2); // 2 meters forward in local space + //this._screenMesh.renderingGroupId = 3; // Always render on top this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection // Create material @@ -165,11 +168,11 @@ export class StatusScreen { 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); + // Score title (changes based on game state) + this._scoreTitleText = this.createTitleText("CURRENT SCORE"); + this._scoreTitleText.fontSize = "50px"; + this._scoreTitleText.height = "70px"; + mainPanel.addControl(this._scoreTitleText); this._finalScoreText = new TextBlock(); this._finalScoreText.text = "0"; @@ -186,22 +189,28 @@ export class StatusScreen { this._scoreBreakdownText.color = "#aaaaaa"; this._scoreBreakdownText.fontSize = "20px"; this._scoreBreakdownText.height = "120px"; + this._scoreBreakdownText.width = "100%"; 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); + // Star ratings container (populated in updateStatistics) + this._starsContainer = new StackPanel("starsContainer"); + this._starsContainer.isVertical = false; + this._starsContainer.height = "100px"; + this._starsContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + mainPanel.addControl(this._starsContainer); + + // Total stars display + this._totalStarsText = new TextBlock(); + this._totalStarsText.text = ""; + this._totalStarsText.color = "#FFD700"; + this._totalStarsText.fontSize = "32px"; + this._totalStarsText.height = "50px"; + this._totalStarsText.fontWeight = "bold"; + this._totalStarsText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + mainPanel.addControl(this._totalStarsText); // Add spacing before buttons const spacer3 = this.createSpacer(40); @@ -213,6 +222,9 @@ export class StatusScreen { buttonBar.height = "80px"; buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; buttonBar.spacing = 20; + buttonBar.paddingLeft = 20; + buttonBar.paddingRight = 20; + buttonBar.clipChildren = false; // Create Resume button (only shown when game hasn't ended) this._resumeButton = Button.CreateSimpleButton("resumeButton", "RESUME GAME"); @@ -224,6 +236,7 @@ export class StatusScreen { this._resumeButton.thickness = 0; this._resumeButton.fontSize = "30px"; this._resumeButton.fontWeight = "bold"; + addButtonHoverEffect(this._resumeButton); this._resumeButton.onPointerClickObservable.add(() => { if (this._onResumeCallback) { this._onResumeCallback(); @@ -275,6 +288,7 @@ export class StatusScreen { this._exitButton.thickness = 0; this._exitButton.fontSize = "30px"; this._exitButton.fontWeight = "bold"; + addButtonHoverEffect(this._exitButton, "#cc3333", "#ff4444"); this._exitButton.onPointerClickObservable.add(() => { if (this._onExitCallback) { this._onExitCallback(); @@ -356,6 +370,33 @@ export class StatusScreen { return spacer; } + /** + * Create a star rating column with stars on top and label below + */ + private createStarRatingColumn(stars: number, label: string): StackPanel { + const column = new StackPanel(); + column.isVertical = true; + column.width = "150px"; + + const starsText = new TextBlock(); + starsText.text = formatStars(stars); + starsText.color = "#FFD700"; + starsText.fontSize = "36px"; + starsText.height = "50px"; + starsText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + column.addControl(starsText); + + const labelText = new TextBlock(); + labelText.text = label; + labelText.color = "#aaaaaa"; + labelText.fontSize = "24px"; + labelText.height = "35px"; + labelText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + column.addControl(labelText); + + return column; + } + /** * Toggle visibility of status screen */ @@ -502,29 +543,35 @@ export class StatusScreen { this._accuracyText.text = `Accuracy: ${stats.accuracy}%`; this._fuelConsumedText.text = `Fuel Consumed: ${stats.fuelConsumed}%`; - // Calculate and display score - const scoreCalc = this._gameStats.calculateFinalScore(this._parTime); + // Calculate score - only include end-game bonuses if game has ended + const scoreCalc = this._gameStats.getFinalScore(this._isGameEnded); - // Update final score + // Update score title based on game state + this._scoreTitleText.text = this._isGameEnded ? "FINAL SCORE" : "CURRENT SCORE"; + + // Update score value 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 score breakdown - show bonuses only at game end + if (this._isGameEnded) { + this._scoreBreakdownText.text = + `Asteroids: ${scoreCalc.asteroidScore.toLocaleString()} | ` + + `Acc: +${scoreCalc.bonuses.accuracy.toLocaleString()}\n` + + `Fuel: +${scoreCalc.bonuses.fuel.toLocaleString()} | ` + + `Hull: +${scoreCalc.bonuses.hull.toLocaleString()}`; + } else { + this._scoreBreakdownText.text = `Points from asteroid destruction`; + } - // 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); + // Rebuild star rating columns + this._starsContainer.clearControls(); + this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.asteroids, "Kills")); + this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.accuracy, "Acc")); + this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.fuel, "Fuel")); + this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.hull, "Hull")); - this._starRatingText.text = - `${timeStars} ${accStars} ${fuelStars} ${hullStars}\n` + - `Time Acc Fuel Hull\n` + - `${scoreCalc.stars.total}/12 Stars`; + // Update total stars + this._totalStarsText.text = `${scoreCalc.stars.total}/12 Stars`; } /** diff --git a/src/ui/screens/preloader.ts b/src/ui/screens/preloader.ts index 21190b3..7bffb57 100644 --- a/src/ui/screens/preloader.ts +++ b/src/ui/screens/preloader.ts @@ -55,7 +55,14 @@ export class Preloader { } private setupButtonHandler(): void { - this.startButton?.addEventListener('click', () => this.onStartCallback?.()); + this.startButton?.addEventListener('click', () => { + if (this.startButton) { + (this.startButton as HTMLButtonElement).disabled = true; + this.startButton.style.opacity = '0.6'; + this.startButton.textContent = 'ENTERING XR...'; + } + this.onStartCallback?.(); + }); } public setLevelInfo(name: string, difficulty: string, missionBrief: string[]): void { diff --git a/src/ui/utils/buttonEffects.ts b/src/ui/utils/buttonEffects.ts new file mode 100644 index 0000000..5b6bc68 --- /dev/null +++ b/src/ui/utils/buttonEffects.ts @@ -0,0 +1,29 @@ +import { Button } from "@babylonjs/gui"; + +/** + * Adds a prominent hover effect to a BabylonJS GUI button + * @param button - The button to add hover effects to + * @param baseBackground - The base background color (default: #00ff88) + * @param hoverBackground - The hover background color (default: #00ffaa) + */ +export function addButtonHoverEffect( + button: Button, + baseBackground: string = "#00ff88", + hoverBackground: string = "#00ffaa" +): void { + button.onPointerEnterObservable.add(() => { + button.background = hoverBackground; + button.scaleX = 1.05; + button.scaleY = 1.05; + button.thickness = 3; + button.color = "#ffffff"; + }); + + button.onPointerOutObservable.add(() => { + button.background = baseBackground; + button.scaleX = 1; + button.scaleY = 1; + button.thickness = 0; + button.color = "white"; + }); +}