Refactor scoring system to additive model starting at 0

- 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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-01 16:39:27 -06:00
parent 64331b4566
commit 749cc18211
11 changed files with 270 additions and 226 deletions

View File

@ -175,10 +175,11 @@ export class RockFactory {
if (eventData.type == 'COLLISION_STARTED') { if (eventData.type == 'COLLISION_STARTED') {
if ( eventData.collidedAgainst.transformNode.id == 'ammo') { if ( eventData.collidedAgainst.transformNode.id == 'ammo') {
log.debug('[RockFactory] ASTEROID HIT! Triggering explosion...'); log.debug('[RockFactory] ASTEROID HIT! Triggering explosion...');
score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed"});
// Get the asteroid mesh before disposing // Get the asteroid mesh before disposing
const asteroidMesh = eventData.collider.transformNode as AbstractMesh; 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:', { log.debug('[RockFactory] Asteroid mesh to explode:', {
name: asteroidMesh.name, name: asteroidMesh.name,
id: asteroidMesh.id, id: asteroidMesh.id,

View File

@ -1,6 +1,6 @@
import { getAnalytics } from "../analytics"; import { getAnalytics } from "../analytics";
import log from "../core/logger"; import log from "../core/logger";
import { calculateScore, ScoreCalculation } from "./scoreCalculator"; import { calculateAsteroidPoints, calculateFinalScore, ScoreCalculation } from "./scoreCalculator";
/** /**
* Tracks game statistics for display on status screen * Tracks game statistics for display on status screen
@ -13,6 +13,8 @@ export class GameStats {
private _shotsHit: number = 0; private _shotsHit: number = 0;
private _fuelConsumed: number = 0; private _fuelConsumed: number = 0;
private _performanceTimer: number | null = null; private _performanceTimer: number | null = null;
private _runningScore: number = 0;
private _parTime: number = 120;
/** /**
* Start the game timer and performance tracking * 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++; 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._shotsFired = 0;
this._shotsHit = 0; this._shotsHit = 0;
this._fuelConsumed = 0; this._fuelConsumed = 0;
this._runningScore = 0;
// Restart performance tracking // Restart performance tracking
this.startPerformanceTracking(); this.startPerformanceTracking();
@ -188,22 +208,20 @@ export class GameStats {
/** /**
* Calculate final score based on current statistics * Calculate final score based on current statistics
* * @param includeEndGameBonuses - Whether to include end-game bonuses (only at game end)
* @param parTime - Expected completion time in seconds (default: 120) * @returns Complete score calculation with bonuses and star ratings
* @returns Complete score calculation with multipliers and star ratings
*/ */
public calculateFinalScore(parTime: number = 120): ScoreCalculation { public getFinalScore(includeEndGameBonuses: boolean = true): ScoreCalculation {
const gameTimeSeconds = this.getGameTime();
const accuracy = this.getAccuracy(); const accuracy = this.getAccuracy();
const fuelConsumed = this._fuelConsumed * 100; // Convert to percentage const fuelConsumed = this._fuelConsumed * 100; // Convert to percentage
const hullDamage = this._hullDamageTaken * 100; // Convert to percentage const hullDamage = this._hullDamageTaken * 100; // Convert to percentage
return calculateScore( return calculateFinalScore(
gameTimeSeconds, this._runningScore,
accuracy,
fuelConsumed,
hullDamage, hullDamage,
parTime fuelConsumed,
accuracy,
includeEndGameBonuses
); );
} }

View File

@ -1,14 +1,18 @@
/** /**
* Score calculation system for space shooter game * Score calculation system for space shooter game
* Uses linear-clamped multipliers that never go negative * Additive scoring: starts at 0, builds through asteroid destruction and end-game bonuses
* Rewards speed, accuracy, fuel efficiency, and hull integrity
*/ */
// 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) * Star rating levels (0-3 stars per category)
*/ */
interface StarRatings { 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 accuracy: number; // 0-3 stars based on shot accuracy
fuel: number; // 0-3 stars based on fuel efficiency fuel: number; // 0-3 stars based on fuel efficiency
hull: number; // 0-3 stars based on hull integrity 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 { export interface EndGameBonuses {
rawFuelConsumed: number; // Actual fuel consumed (can be >100%) hull: number;
rawHullDamage: number; // Actual hull damage (can be >100%) fuel: number;
fuelEfficiency: number; // 0-100 display value (clamped) accuracy: number;
hullIntegrity: number; // 0-100 display value (clamped)
} }
/** /**
* Complete score calculation result * Complete score calculation result
*/ */
export interface ScoreCalculation { export interface ScoreCalculation {
baseScore: number; asteroidScore: number; // Points from destroying asteroids
timeMultiplier: number; bonuses: EndGameBonuses; // End-game bonuses
accuracyMultiplier: number; finalScore: number; // Total score
fuelMultiplier: number;
hullMultiplier: number;
finalScore: number;
stars: StarRatings; 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 { export function calculateAsteroidPoints(
baseScore?: number; // Default: 10000 scale: number,
minMultiplier?: number; // Minimum multiplier floor (default: 0.5) elapsedSeconds: number,
maxTimeMultiplier?: number; // Maximum time bonus (default: 3.0) parTime: number
minTimeMultiplier?: number; // Minimum time multiplier (default: 0.1) ): 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 * Calculate end-game bonuses based on performance
* * @param hullDamage - Total hull damage taken (0-300+%)
* @param gameTimeSeconds - Total game time in seconds * @param fuelConsumed - Total fuel consumed (0-300+%)
* @param accuracy - Shot accuracy percentage (0-100) * @param accuracy - Shot accuracy percentage (0-100%)
* @param fuelConsumed - Fuel consumed percentage (0-, can exceed 100% with refuels) * @returns Bonus points for each category
* @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
*/ */
export function calculateScore( export function calculateEndGameBonuses(
gameTimeSeconds: number,
accuracy: number,
fuelConsumed: number,
hullDamage: number, hullDamage: number,
parTime: number = 120, fuelConsumed: number,
config: ScoreConfig = {} 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 { ): ScoreCalculation {
const { const bonuses = includeEndGameBonuses
baseScore = 10000, ? calculateEndGameBonuses(hullDamage, fuelConsumed, accuracy)
minMultiplier = 0.5, : { hull: 0, fuel: 0, accuracy: 0 };
maxTimeMultiplier = 3.0, const finalScore = asteroidScore + bonuses.hull + bonuses.fuel + bonuses.accuracy;
minTimeMultiplier = 0.1
} = config;
// ============================================
// 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 = { const stars: StarRatings = {
time: getTimeStars(gameTimeSeconds, parTime), asteroids: getAsteroidStars(asteroidScore),
accuracy: getAccuracyStars(accuracy), accuracy: getAccuracyStars(accuracy),
fuel: getFuelStars(fuelConsumed), fuel: getFuelStars(fuelConsumed),
hull: getHullStars(hullDamage), hull: getHullStars(hullDamage),
total: 0 total: 0
}; };
stars.total = stars.time + stars.accuracy + stars.fuel + stars.hull; stars.total = stars.asteroids + 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))
};
return { return {
baseScore, asteroidScore,
timeMultiplier, bonuses,
accuracyMultiplier,
fuelMultiplier,
hullMultiplier,
finalScore, finalScore,
stars, stars
debug
}; };
} }
/** /**
* Calculate time stars based on completion time vs par * Calculate asteroid stars based on score earned
* * Note: This is a rough heuristic; actual thresholds may need tuning per level
* @param seconds - Completion time in seconds
* @param par - Par time in seconds
* @returns 0-3 stars
*/ */
function getTimeStars(seconds: number, par: number): number { function getAsteroidStars(asteroidScore: number): number {
const ratio = seconds / par; // Assumes average ~20,000 pts for good performance
if (ratio <= 0.5) return 3; // Finished in half the par time if (asteroidScore >= 25000) return 3;
if (ratio <= 1.0) return 2; // Finished at or under par if (asteroidScore >= 15000) return 2;
if (ratio <= 1.5) return 1; // Finished within 150% of par if (asteroidScore >= 8000) return 1;
return 0; // Over 150% of par return 0;
} }
/** /**
* Calculate accuracy stars based on hit percentage * Calculate accuracy stars based on hit percentage
*
* @param accuracy - Shot accuracy percentage (0-100) * @param accuracy - Shot accuracy percentage (0-100)
* @returns 0-3 stars * @returns 0-3 stars
*/ */
function getAccuracyStars(accuracy: number): number { function getAccuracyStars(accuracy: number): number {
if (accuracy >= 75) return 3; // Excellent accuracy if (accuracy >= 80) return 3;
if (accuracy >= 50) return 2; // Good accuracy if (accuracy >= 50) return 2;
if (accuracy >= 25) return 1; // Fair accuracy if (accuracy >= 20) return 1;
return 0; // Poor accuracy return 0;
} }
/** /**
* Calculate fuel efficiency stars * Calculate fuel efficiency stars
* * @param fuelConsumed - Fuel consumed percentage (0-300+%)
* @param fuelConsumed - Fuel consumed percentage (0-)
* @returns 0-3 stars * @returns 0-3 stars
*/ */
function getFuelStars(fuelConsumed: number): number { function getFuelStars(fuelConsumed: number): number {
// Stars only consider first 100% of fuel if (fuelConsumed <= 50) return 3;
// Refueling doesn't earn extra stars if (fuelConsumed <= 150) return 2;
if (fuelConsumed <= 30) return 3; // Used ≤30% fuel if (fuelConsumed <= 250) return 1;
if (fuelConsumed <= 60) return 2; // Used ≤60% fuel return 0;
if (fuelConsumed <= 80) return 1; // Used ≤80% fuel
return 0; // Used >80% fuel (including refuels)
} }
/** /**
* Calculate hull integrity stars * Calculate hull integrity stars
* * @param hullDamage - Hull damage percentage (0-300+%)
* @param hullDamage - Hull damage percentage (0-)
* @returns 0-3 stars * @returns 0-3 stars
*/ */
function getHullStars(hullDamage: number): number { function getHullStars(hullDamage: number): number {
// Stars only consider first 100% of damage if (hullDamage <= 30) return 3;
// Dying and respawning = 0 stars if (hullDamage <= 100) return 2;
if (hullDamage <= 10) return 3; // Took ≤10% damage if (hullDamage <= 200) return 1;
if (hullDamage <= 30) return 2; // Took ≤30% damage return 0;
if (hullDamage <= 60) return 1; // Took ≤60% damage
return 0; // Took >60% damage (including deaths)
} }
/** /**
* Get star rating color based on count * Get star rating color based on count
*
* @param stars - Number of stars (0-3) * @param stars - Number of stars (0-3)
* @returns Hex color code * @returns Hex color code
*/ */
@ -245,7 +182,6 @@ export function getStarColor(stars: number): string {
/** /**
* Format stars as Unicode string * Format stars as Unicode string
*
* @param earned - Number of stars earned (0-3) * @param earned - Number of stars earned (0-3)
* @param total - Total possible stars (default: 3) * @param total - Total possible stars (default: 3)
* @returns Unicode star string (e.g., "★★☆") * @returns Unicode star string (e.g., "★★☆")

View File

@ -154,7 +154,7 @@ export class GameResultsService {
// Get stats // Get stats
const stats = gameStats.getStats(); const stats = gameStats.getStats();
const scoreCalc = gameStats.calculateFinalScore(parTime); const scoreCalc = gameStats.getFinalScore();
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),

View File

@ -418,17 +418,17 @@ export class Ship {
this._scoreboard.initialize(); this._scoreboard.initialize();
// Subscribe to score events to track asteroids destroyed // Subscribe to score events to track asteroids destroyed
this._scoreboard.onScoreObservable.add(() => { this._scoreboard.onScoreObservable.add((event) => {
// Each score event represents an asteroid destroyed // Each score event represents an asteroid destroyed, pass scale for point calc
this._gameStats.recordAsteroidDestroyed(); this._gameStats.recordAsteroidDestroyed(event.scale || 1);
// Track asteroid destruction in analytics // Track asteroid destruction in analytics
try { try {
const analytics = getAnalytics(); const analytics = getAnalytics();
analytics.track('asteroid_destroyed', { analytics.track('asteroid_destroyed', {
weaponType: 'laser', // TODO: Get actual weapon type from event weaponType: 'laser',
distance: 0, // TODO: Calculate distance if available distance: 0,
asteroidSize: 0, // TODO: Get actual size if available asteroidSize: event.scale || 0,
remainingCount: this._scoreboard.remaining remainingCount: this._scoreboard.remaining
}, { sampleRate: 0.2 }); // Sample 20% of asteroid events to reduce data }, { sampleRate: 0.2 }); // Sample 20% of asteroid events to reduce data
} catch (error) { } catch (error) {

View File

@ -198,12 +198,14 @@ export class WeaponSystem {
if (isAsteroid) { if (isAsteroid) {
log.debug('[WeaponSystem] Asteroid hit! Triggering destruction...'); log.debug('[WeaponSystem] Asteroid hit! Triggering destruction...');
// Update score // Update score with asteroid scale for point calculation
if (this._scoreObservable) { if (this._scoreObservable) {
const asteroidScale = hitMesh.scaling.x;
this._scoreObservable.notifyObservers({ this._scoreObservable.notifyObservers({
score: 1, score: 1,
remaining: -1, remaining: -1,
message: "Asteroid Destroyed" message: "Asteroid Destroyed",
scale: asteroidScale
}); });
} }

View File

@ -12,6 +12,7 @@ import type { AudioEngineV2 } from "@babylonjs/core";
import log from '../../core/logger'; import log from '../../core/logger';
import { LevelConfig } from "../../levels/config/levelConfig"; import { LevelConfig } from "../../levels/config/levelConfig";
import { CloudLevelEntry } from "../../services/cloudLevelService"; import { CloudLevelEntry } from "../../services/cloudLevelService";
import { addButtonHoverEffect } from "../utils/buttonEffects";
/** /**
* Mission brief display for VR * Mission brief display for VR
@ -48,9 +49,9 @@ export class MissionBrief {
} }
mesh.parent = ship; 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.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 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 parented to ship at position:', mesh.position);
log.info('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition()); log.info('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition());
@ -71,7 +72,7 @@ export class MissionBrief {
this._container.height = "600px"; this._container.height = "600px";
this._container.thickness = 4; this._container.thickness = 4;
this._container.color = "#00ff00"; 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.cornerRadius = 20;
this._container.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; this._container.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
this._container.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; this._container.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER;
@ -202,7 +203,7 @@ export class MissionBrief {
// Spacer before button // Spacer before button
const spacer3 = new Rectangle("spacer3"); const spacer3 = new Rectangle("spacer3");
spacer3.height = "40px"; spacer3.height = "20px";
spacer3.thickness = 0; spacer3.thickness = 0;
contentPanel.addControl(spacer3); contentPanel.addControl(spacer3);
@ -217,6 +218,8 @@ export class MissionBrief {
startButton.fontSize = "36px"; startButton.fontSize = "36px";
startButton.fontWeight = "bold"; startButton.fontWeight = "bold";
startButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; startButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
addButtonHoverEffect(startButton);
startButton.onPointerClickObservable.add(() => { startButton.onPointerClickObservable.add(() => {
log.debug('[MissionBrief] START button clicked - dismissing mission brief'); log.debug('[MissionBrief] START button clicked - dismissing mission brief');
this.hide(); this.hide();

View File

@ -13,7 +13,8 @@ export type ScoreEvent = {
score: number, score: number,
message: string, message: string,
remaining: number, remaining: number,
timeRemaining? : number scale?: number, // Asteroid scale for point calculation
timeRemaining?: number
} }
export class Scoreboard { export class Scoreboard {
private _score: number = 0; private _score: number = 0;

View File

@ -18,6 +18,7 @@ import { GameStats } from "../../game/gameStats";
import { DefaultScene } from "../../core/defaultScene"; import { DefaultScene } from "../../core/defaultScene";
import { ProgressionManager } from "../../game/progression"; import { ProgressionManager } from "../../game/progression";
import { AuthService } from "../../services/authService"; import { AuthService } from "../../services/authService";
import { addButtonHoverEffect } from "../utils/buttonEffects";
import { FacebookShare, ShareData } from "../../services/facebookShare"; import { FacebookShare, ShareData } from "../../services/facebookShare";
import { InputControlManager } from "../../ship/input/inputControlManager"; import { InputControlManager } from "../../ship/input/inputControlManager";
import { formatStars } from "../../game/scoreCalculator"; import { formatStars } from "../../game/scoreCalculator";
@ -46,9 +47,11 @@ export class StatusScreen {
private _fuelConsumedText: TextBlock; private _fuelConsumedText: TextBlock;
// Text blocks for score display // Text blocks for score display
private _scoreTitleText: TextBlock;
private _finalScoreText: TextBlock; private _finalScoreText: TextBlock;
private _scoreBreakdownText: TextBlock; private _scoreBreakdownText: TextBlock;
private _starRatingText: TextBlock; private _starsContainer: StackPanel;
private _totalStarsText: TextBlock;
// Buttons // Buttons
private _replayButton: Button; private _replayButton: Button;
@ -97,8 +100,8 @@ export class StatusScreen {
// Parent to ship for fixed cockpit position // Parent to ship for fixed cockpit position
this._screenMesh.parent = this._shipNode; this._screenMesh.parent = this._shipNode;
this._screenMesh.position = new Vector3(0, 1, 2); // 2 meters forward in local space 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.renderingGroupId = 3; // Always render on top
this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection
// Create material // Create material
@ -165,11 +168,11 @@ export class StatusScreen {
const spacer2b = this.createSpacer(30); const spacer2b = this.createSpacer(30);
mainPanel.addControl(spacer2b); mainPanel.addControl(spacer2b);
// Final score display // Score title (changes based on game state)
const scoreTitle = this.createTitleText("FINAL SCORE"); this._scoreTitleText = this.createTitleText("CURRENT SCORE");
scoreTitle.fontSize = "50px"; this._scoreTitleText.fontSize = "50px";
scoreTitle.height = "70px"; this._scoreTitleText.height = "70px";
mainPanel.addControl(scoreTitle); mainPanel.addControl(this._scoreTitleText);
this._finalScoreText = new TextBlock(); this._finalScoreText = new TextBlock();
this._finalScoreText.text = "0"; this._finalScoreText.text = "0";
@ -186,22 +189,28 @@ export class StatusScreen {
this._scoreBreakdownText.color = "#aaaaaa"; this._scoreBreakdownText.color = "#aaaaaa";
this._scoreBreakdownText.fontSize = "20px"; this._scoreBreakdownText.fontSize = "20px";
this._scoreBreakdownText.height = "120px"; this._scoreBreakdownText.height = "120px";
this._scoreBreakdownText.width = "100%";
this._scoreBreakdownText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; this._scoreBreakdownText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
this._scoreBreakdownText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; this._scoreBreakdownText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
this._scoreBreakdownText.textWrapping = true; this._scoreBreakdownText.textWrapping = true;
mainPanel.addControl(this._scoreBreakdownText); mainPanel.addControl(this._scoreBreakdownText);
// Star ratings // Star ratings container (populated in updateStatistics)
this._starRatingText = new TextBlock(); this._starsContainer = new StackPanel("starsContainer");
this._starRatingText.text = ""; this._starsContainer.isVertical = false;
this._starRatingText.color = "#FFD700"; this._starsContainer.height = "100px";
this._starRatingText.fontSize = "40px"; this._starsContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
this._starRatingText.height = "100px"; mainPanel.addControl(this._starsContainer);
this._starRatingText.fontWeight = "bold";
this._starRatingText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; // Total stars display
this._starRatingText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; this._totalStarsText = new TextBlock();
this._starRatingText.textWrapping = true; this._totalStarsText.text = "";
mainPanel.addControl(this._starRatingText); 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 // Add spacing before buttons
const spacer3 = this.createSpacer(40); const spacer3 = this.createSpacer(40);
@ -213,6 +222,9 @@ export class StatusScreen {
buttonBar.height = "80px"; buttonBar.height = "80px";
buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
buttonBar.spacing = 20; buttonBar.spacing = 20;
buttonBar.paddingLeft = 20;
buttonBar.paddingRight = 20;
buttonBar.clipChildren = false;
// Create Resume button (only shown when game hasn't ended) // Create Resume button (only shown when game hasn't ended)
this._resumeButton = Button.CreateSimpleButton("resumeButton", "RESUME GAME"); this._resumeButton = Button.CreateSimpleButton("resumeButton", "RESUME GAME");
@ -224,6 +236,7 @@ export class StatusScreen {
this._resumeButton.thickness = 0; this._resumeButton.thickness = 0;
this._resumeButton.fontSize = "30px"; this._resumeButton.fontSize = "30px";
this._resumeButton.fontWeight = "bold"; this._resumeButton.fontWeight = "bold";
addButtonHoverEffect(this._resumeButton);
this._resumeButton.onPointerClickObservable.add(() => { this._resumeButton.onPointerClickObservable.add(() => {
if (this._onResumeCallback) { if (this._onResumeCallback) {
this._onResumeCallback(); this._onResumeCallback();
@ -275,6 +288,7 @@ export class StatusScreen {
this._exitButton.thickness = 0; this._exitButton.thickness = 0;
this._exitButton.fontSize = "30px"; this._exitButton.fontSize = "30px";
this._exitButton.fontWeight = "bold"; this._exitButton.fontWeight = "bold";
addButtonHoverEffect(this._exitButton, "#cc3333", "#ff4444");
this._exitButton.onPointerClickObservable.add(() => { this._exitButton.onPointerClickObservable.add(() => {
if (this._onExitCallback) { if (this._onExitCallback) {
this._onExitCallback(); this._onExitCallback();
@ -356,6 +370,33 @@ export class StatusScreen {
return spacer; 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 * Toggle visibility of status screen
*/ */
@ -502,29 +543,35 @@ export class StatusScreen {
this._accuracyText.text = `Accuracy: ${stats.accuracy}%`; this._accuracyText.text = `Accuracy: ${stats.accuracy}%`;
this._fuelConsumedText.text = `Fuel Consumed: ${stats.fuelConsumed}%`; this._fuelConsumedText.text = `Fuel Consumed: ${stats.fuelConsumed}%`;
// Calculate and display score // Calculate score - only include end-game bonuses if game has ended
const scoreCalc = this._gameStats.calculateFinalScore(this._parTime); 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(); this._finalScoreText.text = scoreCalc.finalScore.toLocaleString();
// Update score breakdown // Update score breakdown - show bonuses only at game end
this._scoreBreakdownText.text = if (this._isGameEnded) {
`Time: ${scoreCalc.timeMultiplier.toFixed(2)}x | ` + this._scoreBreakdownText.text =
`Accuracy: ${scoreCalc.accuracyMultiplier.toFixed(2)}x\n` + `Asteroids: ${scoreCalc.asteroidScore.toLocaleString()} | ` +
`Fuel: ${scoreCalc.fuelMultiplier.toFixed(2)}x | ` + `Acc: +${scoreCalc.bonuses.accuracy.toLocaleString()}\n` +
`Hull: ${scoreCalc.hullMultiplier.toFixed(2)}x`; `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 // Rebuild star rating columns
const timeStars = formatStars(scoreCalc.stars.time); this._starsContainer.clearControls();
const accStars = formatStars(scoreCalc.stars.accuracy); this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.asteroids, "Kills"));
const fuelStars = formatStars(scoreCalc.stars.fuel); this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.accuracy, "Acc"));
const hullStars = formatStars(scoreCalc.stars.hull); this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.fuel, "Fuel"));
this._starsContainer.addControl(this.createStarRatingColumn(scoreCalc.stars.hull, "Hull"));
this._starRatingText.text = // Update total stars
`${timeStars} ${accStars} ${fuelStars} ${hullStars}\n` + this._totalStarsText.text = `${scoreCalc.stars.total}/12 Stars`;
`Time Acc Fuel Hull\n` +
`${scoreCalc.stars.total}/12 Stars`;
} }
/** /**

View File

@ -55,7 +55,14 @@ export class Preloader {
} }
private setupButtonHandler(): void { 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 { public setLevelInfo(name: string, difficulty: string, missionBrief: string[]): void {

View File

@ -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";
});
}