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.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,

View File

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

View File

@ -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., "★★☆")

View File

@ -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(),

View File

@ -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) {

View File

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

View File

@ -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();

View File

@ -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;

View File

@ -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`;
}
/**

View File

@ -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 {

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