diff --git a/public/assets/themes/default/audio/explosion.mp3 b/public/assets/themes/default/audio/explosion.mp3 index 5a9c474..1941a88 100644 Binary files a/public/assets/themes/default/audio/explosion.mp3 and b/public/assets/themes/default/audio/explosion.mp3 differ diff --git a/public/assets/themes/default/audio/thrust5.mp3 b/public/assets/themes/default/audio/thrust5.mp3 new file mode 100644 index 0000000..6862760 Binary files /dev/null and b/public/assets/themes/default/audio/thrust5.mp3 differ diff --git a/src/game/gameStats.ts b/src/game/gameStats.ts index 3ff76b2..6f6b0fd 100644 --- a/src/game/gameStats.ts +++ b/src/game/gameStats.ts @@ -1,5 +1,6 @@ import { getAnalytics } from "../analytics"; import debugLog from "../core/debug"; +import { calculateScore, ScoreCalculation } from "./scoreCalculator"; /** * Tracks game statistics for display on status screen @@ -185,6 +186,27 @@ export class GameStats { this.startPerformanceTracking(); } + /** + * 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 + */ + public calculateFinalScore(parTime: number = 120): ScoreCalculation { + const gameTimeSeconds = this.getGameTime(); + 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, + hullDamage, + parTime + ); + } + /** * Cleanup when game ends */ diff --git a/src/game/scoreCalculator.ts b/src/game/scoreCalculator.ts new file mode 100644 index 0000000..1abb48e --- /dev/null +++ b/src/game/scoreCalculator.ts @@ -0,0 +1,257 @@ +/** + * Score calculation system for space shooter game + * Uses linear-clamped multipliers that never go negative + * Rewards speed, accuracy, fuel efficiency, and hull integrity + */ + +/** + * Star rating levels (0-3 stars per category) + */ +export interface StarRatings { + time: number; // 0-3 stars based on completion time + 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 + total: number; // Sum of all star ratings (0-12) +} + +/** + * Debug information for score calculation + */ +export 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) +} + +/** + * Complete score calculation result + */ +export interface ScoreCalculation { + baseScore: number; + timeMultiplier: number; + accuracyMultiplier: number; + fuelMultiplier: number; + hullMultiplier: number; + finalScore: number; + stars: StarRatings; + debug: ScoreDebugInfo; +} + +/** + * Configuration for score calculation + */ +export 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) +} + +/** + * 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 + */ +export function calculateScore( + gameTimeSeconds: number, + accuracy: number, + fuelConsumed: number, + hullDamage: number, + parTime: number = 120, + config: ScoreConfig = {} +): ScoreCalculation { + const { + baseScore = 10000, + minMultiplier = 0.5, + maxTimeMultiplier = 3.0, + 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 = { + time: getTimeStars(gameTimeSeconds, parTime), + 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)) + }; + + return { + baseScore, + timeMultiplier, + accuracyMultiplier, + fuelMultiplier, + hullMultiplier, + finalScore, + stars, + debug + }; +} + +/** + * 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 + */ +export 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 +} + +/** + * Calculate accuracy stars based on hit percentage + * + * @param accuracy - Shot accuracy percentage (0-100) + * @returns 0-3 stars + */ +export 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 +} + +/** + * Calculate fuel efficiency stars + * + * @param fuelConsumed - Fuel consumed percentage (0-∞) + * @returns 0-3 stars + */ +export 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) +} + +/** + * Calculate hull integrity stars + * + * @param hullDamage - Hull damage percentage (0-∞) + * @returns 0-3 stars + */ +export 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) +} + +/** + * Get star rating color based on count + * + * @param stars - Number of stars (0-3) + * @returns Hex color code + */ +export function getStarColor(stars: number): string { + switch (stars) { + case 3: return '#FFD700'; // Gold + case 2: return '#C0C0C0'; // Silver + case 1: return '#CD7F32'; // Bronze + default: return '#808080'; // Gray + } +} + +/** + * 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., "★★☆") + */ +export function formatStars(earned: number, total: number = 3): string { + const filled = '★'.repeat(Math.min(earned, total)); + const empty = '☆'.repeat(Math.max(0, total - earned)); + return filled + empty; +} diff --git a/src/levels/config/levelConfig.ts b/src/levels/config/levelConfig.ts index 73446e3..1a3e585 100644 --- a/src/levels/config/levelConfig.ts +++ b/src/levels/config/levelConfig.ts @@ -135,6 +135,7 @@ export interface LevelConfig { description?: string; babylonVersion?: string; captureTime?: number; + parTime?: number; // Expected completion time in seconds for scoring [key: string]: any; }; diff --git a/src/levels/level1.ts b/src/levels/level1.ts index 8fed929..1354fdf 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -32,7 +32,7 @@ export class Level1 implements Level { private _audioEngine: AudioEngineV2; private _deserializer: LevelDeserializer; private _backgroundStars: BackgroundStars; - private _physicsRecorder: PhysicsRecorder; + private _physicsRecorder: PhysicsRecorder | null = null; private _isReplayMode: boolean; private _backgroundMusic: StaticSound; private _missionBrief: MissionBrief; @@ -331,17 +331,10 @@ export class Level1 implements Level { // Only create recorder in game mode, not replay mode if (!this._isReplayMode) { setLoadingMessage("Initializing physics recorder..."); - this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig); + //this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig); debugLog('Physics recorder initialized (will start on XR pose)'); } - // Wire up recording keyboard shortcuts (only in game mode) - if (!this._isReplayMode) { - this._ship.keyboardInput.onRecordingActionObservable.add((action) => { - this.handleRecordingAction(action); - }); - } - // Load background music before marking as ready if (this._audioEngine) { setLoadingMessage("Loading background music..."); @@ -364,47 +357,39 @@ export class Level1 implements Level { this._initialized = true; + // Set par time for score calculation based on difficulty + const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty); + const statusScreen = (this._ship as any)._statusScreen; // Access private status screen + if (statusScreen) { + statusScreen.setParTime(parTime); + debugLog(`Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`); + } + // Notify that initialization is complete this._onReadyObservable.notifyObservers(this); } /** - * Handle recording keyboard shortcuts + * Get par time based on difficulty level + * Can be overridden by level config metadata */ - private handleRecordingAction(action: string): void { - switch (action) { - case "exportRingBuffer": - // R key: Export last 30 seconds from ring buffer - const ringRecording = this._physicsRecorder.exportRingBuffer(30); - this._physicsRecorder.downloadRecording(ringRecording, "ring-buffer-30s"); - debugLog("Exported ring buffer (last 30 seconds)"); - break; - - case "toggleLongRecording": - // Ctrl+R: Toggle long recording - const stats = this._physicsRecorder.getStats(); - if (stats.isLongRecording) { - this._physicsRecorder.stopLongRecording(); - debugLog("Long recording stopped"); - } else { - this._physicsRecorder.startLongRecording(); - debugLog("Long recording started"); - } - break; - - case "exportLongRecording": - // Shift+R: Export long recording - const longRecording = this._physicsRecorder.exportLongRecording(); - if (longRecording.snapshots.length > 0) { - this._physicsRecorder.downloadRecording(longRecording, "long-recording"); - debugLog("Exported long recording"); - } else { - debugLog("No long recording data to export"); - } - break; + private getParTimeForDifficulty(difficulty: string): number { + // Check if level config has explicit par time + if (this._levelConfig.metadata?.parTime) { + return this._levelConfig.metadata.parTime; } - } + // Default par times by difficulty + const difficultyMap: { [key: string]: number } = { + 'recruit': 300, // 5 minutes + 'pilot': 180, // 3 minutes + 'captain': 120, // 2 minutes + 'commander': 90, // 1.5 minutes + 'test': 60 // 1 minute + }; + + return difficultyMap[difficulty.toLowerCase()] || 120; // Default to 2 minutes + } /** * Get the physics recorder instance diff --git a/src/ship/shipAudio.ts b/src/ship/shipAudio.ts index 4b43f16..f3720d3 100644 --- a/src/ship/shipAudio.ts +++ b/src/ship/shipAudio.ts @@ -54,7 +54,7 @@ export class ShipAudio { "/assets/themes/default/audio/collision.mp3", { loop: false, - volume: 0.35, + volume: 0.25, } ); } diff --git a/src/ship/shipPhysics.ts b/src/ship/shipPhysics.ts index 179eee7..532ff38 100644 --- a/src/ship/shipPhysics.ts +++ b/src/ship/shipPhysics.ts @@ -20,7 +20,7 @@ export interface ForceApplicationResult { export class ShipPhysics { private _shipStatus: ShipStatus | null = null; private _gameStats: GameStats | null = null; - + private _config = GameConfig.getInstance().shipPhysics; /** * Set the ship status instance for fuel consumption tracking */ @@ -49,11 +49,10 @@ export class ShipPhysics { if (!physicsBody) { return { linearMagnitude: 0, angularMagnitude: 0 }; } - const { leftStick, rightStick } = inputState; // Get physics config - const config = GameConfig.getInstance().shipPhysics; + // Get current velocities for velocity cap checks const currentLinearVelocity = physicsBody.getLinearVelocity(); @@ -70,7 +69,7 @@ export class ShipPhysics { // Check if we have fuel before applying force if (this._shipStatus && this._shipStatus.fuel > 0) { // Only apply force if we haven't reached max velocity - if (currentSpeed < config.maxLinearVelocity) { + if (currentSpeed < this._config.maxLinearVelocity) { // Get local direction (Z-axis for forward/backward thrust) const localDirection = new Vector3(0, 0, -leftStick.y); // Transform to world space @@ -78,7 +77,7 @@ export class ShipPhysics { localDirection, transformNode.getWorldMatrix() ); - const force = worldDirection.scale(config.linearForceMultiplier); + const force = worldDirection.scale(this._config.linearForceMultiplier); // Calculate thrust point: center of mass + offset (0, 1, 0) in world space const thrustPoint = Vector3.TransformCoordinates( @@ -113,14 +112,14 @@ export class ShipPhysics { const currentAngularSpeed = currentAngularVelocity.length(); // Only apply torque if we haven't reached max angular velocity - if (currentAngularSpeed < config.maxAngularVelocity) { + if (currentAngularSpeed < this._config.maxAngularVelocity) { const yaw = -leftStick.x; const pitch = rightStick.y; const roll = rightStick.x; // Create torque in local space, then transform to world space const localTorque = new Vector3(pitch, yaw, roll).scale( - config.angularForceMultiplier + this._config.angularForceMultiplier ); const worldTorque = Vector3.TransformNormal( localTorque, diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts index e4bba41..d31c635 100644 --- a/src/ui/hud/statusScreen.ts +++ b/src/ui/hud/statusScreen.ts @@ -20,6 +20,7 @@ import { ProgressionManager } from "../../game/progression"; import { AuthService } from "../../services/authService"; import { FacebookShare, ShareData } from "../../services/facebookShare"; import { InputControlManager } from "../../ship/input/inputControlManager"; +import { formatStars, getStarColor } from "../../game/scoreCalculator"; /** * Status screen that displays game statistics @@ -32,6 +33,7 @@ export class StatusScreen { private _texture: AdvancedDynamicTexture | null = null; private _isVisible: boolean = false; private _camera: Camera | null = null; + private _parTime: number = 120; // Default par time in seconds // Text blocks for statistics private _gameTimeText: TextBlock; @@ -41,6 +43,11 @@ export class StatusScreen { private _accuracyText: TextBlock; private _fuelConsumedText: TextBlock; + // Text blocks for score display + private _finalScoreText: TextBlock; + private _scoreBreakdownText: TextBlock; + private _starRatingText: TextBlock; + // Buttons private _replayButton: Button; private _exitButton: Button; @@ -78,7 +85,7 @@ export class StatusScreen { // Create a plane mesh for the status screen this._screenMesh = MeshBuilder.CreatePlane( "statusScreen", - { width: 1.5, height: 1.0 }, + { width: 1.5, height: 2.25 }, this._scene ); @@ -96,7 +103,7 @@ export class StatusScreen { this._texture = AdvancedDynamicTexture.CreateForMesh( this._screenMesh, 1024, - 768 + 1536 ); this._texture.background = "#1a1a2e"; @@ -137,10 +144,63 @@ export class StatusScreen { this._fuelConsumedText = this.createStatText("Fuel Consumed: 0%"); mainPanel.addControl(this._fuelConsumedText); - // Add spacing before buttons - const spacer2 = this.createSpacer(50); + // Add spacing before score section + const spacer2 = this.createSpacer(40); mainPanel.addControl(spacer2); + // Score section divider + const scoreDivider = new Rectangle("scoreDivider"); + scoreDivider.height = "2px"; + scoreDivider.width = "700px"; + scoreDivider.background = "#00ff88"; + scoreDivider.thickness = 0; + mainPanel.addControl(scoreDivider); + + const spacer2b = this.createSpacer(30); + mainPanel.addControl(spacer2b); + + // Final score display + const scoreTitle = this.createTitleText("FINAL SCORE"); + scoreTitle.fontSize = "50px"; + scoreTitle.height = "70px"; + mainPanel.addControl(scoreTitle); + + this._finalScoreText = new TextBlock(); + this._finalScoreText.text = "0"; + this._finalScoreText.color = "#FFD700"; // Gold color + this._finalScoreText.fontSize = "80px"; + this._finalScoreText.height = "100px"; + this._finalScoreText.fontWeight = "bold"; + this._finalScoreText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + mainPanel.addControl(this._finalScoreText); + + // Score breakdown + this._scoreBreakdownText = new TextBlock(); + this._scoreBreakdownText.text = ""; + this._scoreBreakdownText.color = "#aaaaaa"; + this._scoreBreakdownText.fontSize = "20px"; + this._scoreBreakdownText.height = "120px"; + this._scoreBreakdownText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + this._scoreBreakdownText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; + this._scoreBreakdownText.textWrapping = true; + mainPanel.addControl(this._scoreBreakdownText); + + // Star ratings + this._starRatingText = new TextBlock(); + this._starRatingText.text = ""; + this._starRatingText.color = "#FFD700"; + this._starRatingText.fontSize = "40px"; + this._starRatingText.height = "100px"; + this._starRatingText.fontWeight = "bold"; + this._starRatingText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + this._starRatingText.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; + this._starRatingText.textWrapping = true; + mainPanel.addControl(this._starRatingText); + + // Add spacing before buttons + const spacer3 = this.createSpacer(40); + mainPanel.addControl(spacer3); + // Create button bar const buttonBar = new StackPanel("buttonBar"); buttonBar.isVertical = false; @@ -309,6 +369,14 @@ export class StatusScreen { this._currentLevelName = levelName; } + /** + * Set the par time for score calculation + * @param parTime - Expected completion time in seconds + */ + public setParTime(parTime: number): void { + this._parTime = parTime; + } + /** * Show the status screen * @param isGameEnded - true if game has ended (death/stranded/victory), false if manually paused @@ -417,6 +485,30 @@ export class StatusScreen { this._shotsFiredText.text = `Shots Fired: ${stats.shotsFired}`; this._accuracyText.text = `Accuracy: ${stats.accuracy}%`; this._fuelConsumedText.text = `Fuel Consumed: ${stats.fuelConsumed}%`; + + // Calculate and display score + const scoreCalc = this._gameStats.calculateFinalScore(this._parTime); + + // Update final score + this._finalScoreText.text = scoreCalc.finalScore.toLocaleString(); + + // Update score breakdown + this._scoreBreakdownText.text = + `Time: ${scoreCalc.timeMultiplier.toFixed(2)}x | ` + + `Accuracy: ${scoreCalc.accuracyMultiplier.toFixed(2)}x\n` + + `Fuel: ${scoreCalc.fuelMultiplier.toFixed(2)}x | ` + + `Hull: ${scoreCalc.hullMultiplier.toFixed(2)}x`; + + // Update star ratings with Unicode stars and colors + const timeStars = formatStars(scoreCalc.stars.time); + const accStars = formatStars(scoreCalc.stars.accuracy); + const fuelStars = formatStars(scoreCalc.stars.fuel); + const hullStars = formatStars(scoreCalc.stars.hull); + + this._starRatingText.text = + `${timeStars} ${accStars} ${fuelStars} ${hullStars}\n` + + `Time Acc Fuel Hull\n` + + `${scoreCalc.stars.total}/12 Stars`; } /**