RETROSPORT MANAGER

Season 1 - Race 1: Indianapolis

Team: Red Falcon
Money: $1,250,000

Race Progress

Lap 0/15 Dry

Race Track

Your browser does not support the HTML5 canvas tag.

Race Controls

Driver Status

MAX SPEED P?

100%
0%

SAM RACER P?

100%
0%

Standings

Pit Strategy

Messages

```typescript // === retro_manager_game.ts === // Compile using: tsc retro_manager_game.ts --module esnext --target es6 --outDir . // Creates retro_manager_game.js // --- Interfaces & Types --- interface Point { x: number; y: number; } type TrackId = 'indianapolis' | 'laguna_seca' | 'cota' | 'road_america' | 'talladega' | 'watkins_glen'; interface TrackDefinition { name: string; laps: number; path: Point[]; // Coordinate path for cars backgroundImageSrc: string; // Path to track background image // Add other track-specific data if needed (e.g., pit lane path) } interface SpriteAnimation { frames: number[]; // Array of frame indices on the spritesheet speed: number; // Animation speed in frames per second loop: boolean; } interface SpriteConfig { imageSrc: string; frameWidth: number; frameHeight: number; animations: { [key: string]: SpriteAnimation }; scale?: number; } interface CarState { id: string; // HTML element ID element: HTMLElement | null; // Reference to the (unused) div element if needed later name: string; team: string; color: string; // For drawing placeholder if sprite fails sprite: Sprite; // Sprite instance for this car // Gameplay State positionPercent: number; // Progress along the current path segment (0-1) - Not used in current path logic currentPathIndex: number; // Index of the *target* point in the track path array lap: number; retired: boolean; mode: 'push' | 'conserve' | 'auto'; // Driving mode speedFactorBase: number; // Base speed multiplier fuel: number; // Fuel percentage (0-100) tyreWear: number; // Tyre wear percentage (0-100) // Path Following State x: number; // Current X position on canvas y: number; // Current Y position on canvas angle: number; // Current angle of movement } // --- Asset Loader --- async function loadImages(sources: { [key: string]: string }): Promise<{ [key: string]: HTMLImageElement }> { const images: { [key: string]: HTMLImageElement } = {}; const promises: Promise[] = []; console.log("Loading images:", Object.values(sources)); for (const key in sources) { images[key] = new Image(); promises.push(new Promise((resolve, reject) => { images[key].onload = () => { console.log(`Image loaded: ${sources[key]}`); resolve(); }; images[key].onerror = (err) => { console.error(`Failed to load image: ${sources[key]}`, err); resolve(); }; // Resolve even on error images[key].src = sources[key]; })); } await Promise.all(promises); console.log("Image loading process finished."); return images; } // --- Sprite Class --- class Sprite { image: HTMLImageElement | null = null; isLoaded: boolean = false; frameWidth: number; frameHeight: number; scale: number; animations: { [key: string]: SpriteAnimation }; framesPerRow: number = 0; currentAnimation: string | null = null; currentFrameIndex: number = 0; frameTimer: number = 0; constructor(config: SpriteConfig) { this.frameWidth = config.frameWidth; this.frameHeight = config.frameHeight; this.animations = config.animations; this.scale = config.scale ?? 1; } setImage(image: HTMLImageElement) { this.image = image; this.isLoaded = image.complete && image.naturalHeight !== 0; // Check if actually loaded if (this.isLoaded) { this.framesPerRow = Math.floor(this.image.width / this.frameWidth); if (!this.currentAnimation && Object.keys(this.animations).length > 0) { this.setAnimation(Object.keys(this.animations)[0]); } } else { console.warn(`Image ${image.src} not fully loaded when setting sprite.`); // Attempt to set loaded status on image load event this.image.onload = () => { console.log(`Image ${image.src} loaded after assignment.`); this.isLoaded = true; this.framesPerRow = Math.floor(this.image.width / this.frameWidth); if (!this.currentAnimation && Object.keys(this.animations).length > 0) { this.setAnimation(Object.keys(this.animations)[0]); } } } } setAnimation(name: string) { if (this.currentAnimation === name || !this.animations[name]) return; this.currentAnimation = name; this.currentFrameIndex = 0; this.frameTimer = 0; } update(deltaTime: number) { if (!this.isLoaded || !this.currentAnimation) return; const anim = this.animations[this.currentAnimation]; if (!anim || anim.frames.length <= 1) return; this.frameTimer += deltaTime; const timePerFrame = 1 / anim.speed; while (this.frameTimer >= timePerFrame) { this.frameTimer -= timePerFrame; this.currentFrameIndex++; if (this.currentFrameIndex >= anim.frames.length) { this.currentFrameIndex = anim.loop ? 0 : anim.frames.length - 1; } } } draw(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number = 0) { if (!this.isLoaded || !this.image || !this.currentAnimation) return; const anim = this.animations[this.currentAnimation]; if (!anim) return; const frameNumber = anim.frames[this.currentFrameIndex]; const frameCol = frameNumber % this.framesPerRow; const frameRow = Math.floor(frameNumber / this.framesPerRow); const sx = frameCol * this.frameWidth; const sy = frameRow * this.frameHeight; const drawWidth = this.frameWidth * this.scale; const drawHeight = this.frameHeight * this.scale; const drawX = -drawWidth / 2; // Draw relative to center for rotation const drawY = -drawHeight / 2; ctx.save(); ctx.translate(Math.floor(x), Math.floor(y)); // Move origin to car's position ctx.rotate(angle); // Rotate around the new origin ctx.drawImage( this.image, sx, sy, this.frameWidth, this.frameHeight, drawX, drawY, drawWidth, drawHeight ); ctx.restore(); // Restore original transform state } } // --- Constants & Configuration --- const CANVAS_WIDTH = 320; const CANVAS_HEIGHT = 240; const PLAYER_TEAM_NAME = "Red Falcon"; // Used for highlighting standings const UPDATE_INTERVAL_MS = 50; // Update game state more frequently (20 FPS logic) const PIXELS_PER_METER = 2; // Example scaling factor for speed calculations // --- Track Definitions --- // Define paths relative to the 320x240 canvas const trackDefinitions: Record = { indianapolis: { name: "Indianapolis", laps: 15, backgroundImageSrc: './assets/track_indy.png', path: [ { x: 40, y: 120 }, { x: 80, y: 60 }, { x: 240, y: 60 }, { x: 280, y: 120 }, { x: 240, y: 180 }, { x: 80, y: 180 }, { x: 40, y: 120 } ] // Closed loop implicitly }, laguna_seca: { name: "Laguna Seca", laps: 12, backgroundImageSrc: './assets/track_laguna.png', // Placeholder path: [ { x: 40, y: 200 }, { x: 250, y: 200 }, { x: 280, y: 170 }, { x: 260, y: 140 }, { x: 220, y: 150 }, { x: 180, y: 120 }, { x: 190, y: 80 }, { x: 160, y: 50 }, { x: 120, y: 50 }, { x: 100, y: 80 }, { x: 120, y: 120 }, { x: 80, y: 160 }, { x: 40, y: 200 } ] }, cota: { name: "COTA", laps: 10, backgroundImageSrc: './assets/track_cota.png', // Placeholder path: [ { x: 40, y: 200 }, { x: 200, y: 200 }, { x: 280, y: 150 }, { x: 280, y: 80 }, { x: 240, y: 40 }, { x: 180, y: 40 }, { x: 150, y: 70 }, { x: 160, y: 100 }, { x: 140, y: 130 }, { x: 100, y: 120 }, { x: 80, y: 150 }, { x: 80, y: 180 }, { x: 40, y: 200 } ] }, road_america: { name: "Road America", laps: 8, backgroundImageSrc: './assets/track_roadamerica.png', // Placeholder path: [ { x: 40, y: 180 }, { x: 280, y: 180 }, { x: 300, y: 150 }, { x: 280, y: 120 }, { x: 240, y: 140 }, { x: 200, y: 100 }, { x: 180, y: 60 }, { x: 120, y: 40 }, { x: 80, y: 60 }, { x: 60, y: 100 }, { x: 80, y: 140 }, { x: 40, y: 180 } ] }, talladega: { name: "Talladega", laps: 18, backgroundImageSrc: './assets/track_talladega.png', // Placeholder path: [ { x: 40, y: 120 }, { x: 90, y: 50 }, { x: 230, y: 50 }, { x: 280, y: 120 }, { x: 230, y: 190 }, { x: 90, y: 190 }, { x: 40, y: 120 } ] // Wider oval }, watkins_glen: { name: "Watkins Glen", laps: 10, backgroundImageSrc: './assets/track_watkins.png', // Placeholder path: [ { x: 40, y: 190 }, { x: 180, y: 190 }, { x: 220, y: 160 }, { x: 200, y: 130 }, { x: 220, y: 100 }, { x: 260, y: 100 }, { x: 280, y: 70 }, { x: 260, y: 40 }, { x: 180, y: 40 }, { x: 140, y: 70 }, { x: 160, y: 110 }, { x: 120, y: 150 }, { x: 60, y: 150 }, { x: 40, y: 190 } ] } }; // --- Game State Variables --- let currentTrackId: TrackId = 'indianapolis'; let cars: CarState[] = []; let loadedAssets: { [key: string]: HTMLImageElement } = {}; let carSpriteSheet: HTMLImageElement | null = null; let currentTrackDef: TrackDefinition = trackDefinitions[currentTrackId]; let currentTrackBackground: HTMLImageElement | null = null; let raceInterval: number | null = null; let animationFrameId: number | null = null; // Store requestAnimationFrame ID let isRaceRunning: boolean = false; let playerDriverModes: { [key: number]: 'push' | 'conserve' } = { 1: 'conserve', 2: 'conserve' }; let gameTime = 0; // Simple game time counter // --- DOM Element References --- const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d'); const trackSelect = document.getElementById('track-select') as HTMLSelectElement; const raceIdentifierElement = document.getElementById('race-identifier') as HTMLElement; const lapCounterElement = document.getElementById('lap-counter') as HTMLElement; const weatherStatusElement = document.getElementById('weather-status') as HTMLElement; // Placeholder const startButton = document.getElementById('start-btn') as HTMLButtonElement; const pauseButton = document.getElementById('pause-btn') as HTMLButtonElement; const retireButton = document.getElementById('retire-btn') as HTMLButtonElement; const raceLog = document.getElementById('race-log') as HTMLElement; const standingsList = document.getElementById('standings-list') as HTMLElement; const driverElements = [ // Assuming max 2 player drivers { fuelFill: document.getElementById('driver1-fuel-fill'), fuelText: document.getElementById('driver1-fuel-text'), tyreFill: document.getElementById('driver1-tyre-fill'), tyreText: document.getElementById('driver1-tyre-text'), pos: document.getElementById('driver1-pos'), actionButtons: document.querySelectorAll('.driver-actions button[data-driver="1"]') }, { fuelFill: document.getElementById('driver2-fuel-fill'), fuelText: document.getElementById('driver2-fuel-text'), tyreFill: document.getElementById('driver2-tyre-fill'), tyreText: document.getElementById('driver2-tyre-text'), pos: document.getElementById('driver2-pos'), actionButtons: document.querySelectorAll('.driver-actions button[data-driver="2"]') } ]; // --- Initialization Functions --- /** Populates the track selector dropdown */ function populateTrackSelector() { if (!trackSelect) return; // Clear existing options first trackSelect.innerHTML = ''; Object.keys(trackDefinitions).forEach(trackId => { const option = document.createElement('option'); option.value = trackId; option.textContent = trackDefinitions[trackId as TrackId].name; trackSelect.appendChild(option); }); trackSelect.value = currentTrackId; } /** Creates the initial car data structures */ function initializeCarData() { const carBaseConfig: Omit = { id: '', element: null, name: '', team: '', color: '', retired: false, mode: 'auto', speedFactorBase: 1.0, fuel: 100, tyreWear: 0 }; // Define car roster const roster = [ { ...carBaseConfig, id: 'car1', name: 'MAX SPEED', team: PLAYER_TEAM_NAME, color: '#ff0000', mode: playerDriverModes[1] }, { ...carBaseConfig, id: 'car2', name: 'SAM RACER', team: PLAYER_TEAM_NAME, color: '#dc2626', mode: playerDriverModes[2] }, { ...carBaseConfig, id: 'car3', name: 'CHRIS FAST', team: 'Yellow Storm', color: '#ffff00' }, { ...carBaseConfig, id: 'car4', name: 'DANNY ZOOM', team: 'Green Arrow', color: '#00ff00' }, { ...carBaseConfig, id: 'car5', name: 'ALEX TURBO', team: 'Blue Light', color: '#0088ff' }, // Add more cars up to 8 if needed ]; // Define sprite config (assuming one sheet for all cars for now) const carSpriteDefinition: SpriteConfig = { imageSrc: './assets/cars_spritesheet.png', // Will be replaced by loaded asset frameWidth: 16, frameHeight: 16, scale: 1.5, animations: { idle: { frames: [0], speed: 1, loop: false }, // Frame 0 = default/idle drive: { frames: [0, 1], speed: 10, loop: true }, // Example driving animation // Add turn_left, turn_right etc. based on your sheet } }; cars = roster.map((cfg, index) => { const sprite = new Sprite(carSpriteDefinition); if (carSpriteSheet) sprite.setImage(carSpriteSheet); // Assign preloaded sheet const startOffset = index * 3; // Stagger start position along path // Ensure path exists before accessing it const path = currentTrackDef?.path ?? []; const startPathIndex = path.length > 0 ? startOffset % path.length : 0; const startPoint = path[startPathIndex]; return { ...cfg, sprite: sprite, x: startPoint?.x ?? 50, // Default position if path is empty y: startPoint?.y ?? 50, angle: 0, currentPathIndex: startPathIndex, positionPercent: 0, lap: 0, speedFactorBase: 18 + Math.random() * 4, // Base speed in canvas units/sec }; }); console.log("Car data initialized:", cars.length); } /** Sets the current track, loads background, resets state */ async function setTrack(trackId: TrackId) { if (!trackDefinitions[trackId]) return; const wasRunning = isRaceRunning; if (wasRunning) pauseRace(); currentTrackId = trackId; currentTrackDef = trackDefinitions[trackId]; console.log(`Setting track to: ${currentTrackDef.name}`); // Update UI Text raceIdentifierElement.textContent = `Season 1 - Race 1: ${currentTrackDef.name}`; lapCounterElement.textContent = `Lap 0/${currentTrackDef.laps}`; // Load new background image - Ensure it's loaded before proceeding const bgKey = currentTrackDef.backgroundImageSrc; // Use the src as the key currentTrackBackground = loadedAssets[bgKey] ?? null; if (!currentTrackBackground || !currentTrackBackground.complete || currentTrackBackground.naturalHeight === 0) { console.warn(`Background image for ${currentTrackDef.name} not pre-loaded or invalid. Attempting load now.`); try { const singleImage = await loadImages({ bg: bgKey }); // Load just this one currentTrackBackground = singleImage.bg; if (!currentTrackBackground || !currentTrackBackground.complete || currentTrackBackground.naturalHeight === 0) { console.error(`Failed to load background for ${currentTrackDef.name}.`); currentTrackBackground = null; } else { loadedAssets[bgKey] = currentTrackBackground; // Store if successful console.log(`Successfully loaded background for ${currentTrackDef.name} on demand.`); } } catch (err) { console.error(`Error loading background for ${currentTrackDef.name}:`, err); currentTrackBackground = null; } } else { console.log(`Using pre-loaded background for ${currentTrackDef.name}`); } // Clear log and add track set message if (raceLog) raceLog.innerHTML = ''; addRaceMessage(`Track set to ${currentTrackDef.name}. ${currentTrackDef.laps} laps.`); // Reset car data and positions for the new track path initializeCarData(); // Re-create car state objects resetVisualsAndState(); // Reset positions, UI, etc. // Reset button states startButton.disabled = false; retireButton.disabled = false; pauseButton.disabled = true; startButton.classList.remove('opacity-50', 'cursor-not-allowed'); retireButton.classList.remove('opacity-50', 'cursor-not-allowed'); pauseButton.classList.add('opacity-50', 'cursor-not-allowed'); document.querySelectorAll('.driver-actions button').forEach(btn => (btn as HTMLButtonElement).disabled = true); // Disable actions until race starts // Immediately draw the new state (background + reset cars) if (!animationFrameId) { // Start drawing loop if not already running requestAnimationFrame(animationFrame); } else { draw(); // Otherwise, just draw the current state } } /** Resets car positions, UI elements, and game state */ function resetVisualsAndState() { // Reset car positions based on the current path const path = currentTrackDef?.path ?? []; cars.forEach((car, index) => { const startOffset = index * 3; const startPathIndex = path.length > 0 ? startOffset % path.length : 0; const startPoint = path[startPathIndex]; car.currentPathIndex = startPathIndex; car.positionPercent = 0; car.x = startPoint?.x ?? 50; car.y = startPoint?.y ?? 50; car.lap = 0; car.retired = false; car.fuel = 100; car.tyreWear = 0; car.angle = 0; // Reset angle // Recalculate initial angle based on first segment if (path.length > 1) { const nextIndex = (startPathIndex + 1) % path.length; const nextPoint = path[nextIndex]; car.angle = Math.atan2(nextPoint.y - car.y, nextPoint.x - car.x); } car.mode = (index < 2) ? playerDriverModes[index + 1] : 'auto'; car.sprite.setAnimation('idle'); // Reset sprite animation }); console.log("Car visual and state reset."); // Reset UI lapCounterElement.textContent = `Lap 0/${currentTrackDef.laps}`; updateStandings(); resetDriverStatusUI(); } /** Resets driver status UI panels */ function resetDriverStatusUI() { driverElements.forEach((ui, index) => { if (!ui) return; const driverIndex = index + 1; // 1 or 2 if (ui.pos) ui.pos.textContent = `P?`; if (ui.fuelFill) ui.fuelFill.style.width = `100%`; if (ui.fuelText) ui.fuelText.textContent = `100%`; if (ui.tyreFill) { ui.tyreFill.style.width = `0%`; ui.tyreFill.classList.remove('bg-orange-500', 'bg-red-600'); ui.tyreFill.classList.add('bg-green-500'); } if (ui.tyreText) ui.tyreText.textContent = `0%`; // Reset button visual state ui.actionButtons?.forEach(button => { (button as HTMLButtonElement).classList.remove('opacity-50'); if ((button as HTMLButtonElement).dataset.action === playerDriverModes[driverIndex]) { (button as HTMLButtonElement).classList.add('opacity-50'); } (button as HTMLButtonElement).disabled = true; // Disable until race starts }); }); } // --- Update Functions --- /** Main game state update logic */ function update(deltaTime: number) { if (!isRaceRunning) return; gameTime += deltaTime; let playerLapCompletedThisFrame = false; // Track if player 1 finished a lap *this frame* let playerCarLap = cars[0]?.lap ?? 0; // Track player 1's lap for main counter cars.forEach((car, index) => { if (car.retired) return; const prevLap = car.lap; // --- Path Following --- const path = currentTrackDef.path; if (path.length < 2) return; // Need a path let targetIndex = car.currentPathIndex; let startPoint = path[(targetIndex === 0) ? path.length - 1 : targetIndex - 1]; let targetPoint = path[targetIndex]; // --- Calculate Speed --- let currentSpeed = car.speedFactorBase; // Base speed in canvas units/sec switch(car.mode) { case 'push': currentSpeed *= 1.2; break; case 'conserve': currentSpeed *= 0.8; break; } currentSpeed *= (1.0 - (car.tyreWear / 250)); // Reduce speed slightly with tyre wear (less harsh) currentSpeed = Math.max(5, currentSpeed); // Minimum speed // --- Update Position along Path --- let distanceToMove = currentSpeed * deltaTime; // Move car potentially across multiple segments in one frame while (distanceToMove > 0) { const segmentDx = targetPoint.x - car.x; const segmentDy = targetPoint.y - car.y; const distanceToTarget = Math.sqrt(segmentDx * segmentDx + segmentDy * segmentDy); // Update angle based on current segment direction if (distanceToTarget > 0.1) { // Avoid calculating angle for tiny distances car.angle = Math.atan2(segmentDy, segmentDx); } if (distanceToTarget <= distanceToMove) { // Reached or passed the target point distanceToMove -= distanceToTarget; // Subtract distance covered car.x = targetPoint.x; car.y = targetPoint.y; car.currentPathIndex = (car.currentPathIndex + 1) % path.length; // Move to next target index // Check for crossing the start/finish line (when target becomes index 0 AFTER advancing) if (car.currentPathIndex === 0) { car.lap++; // If this is player 1, update the main lap counter if (index === 0) { playerCarLap = car.lap; // Update tracked lap playerLapCompletedThisFrame = true; // Signal that the main counter needs update } } // Update start/target points for the next iteration (if distanceToMove > 0) targetIndex = car.currentPathIndex; targetPoint = path[targetIndex]; startPoint = path[(targetIndex === 0) ? path.length - 1 : targetIndex - 1]; // If we've completed a full loop and still have move distance, break to avoid infinite loop on tiny paths/high speeds if (car.currentPathIndex === (index * 3 % path.length) && distanceToMove > 0 && distanceToTarget < 1) { // console.warn(`Potential infinite loop detected for car ${car.id}, breaking move loop.`); distanceToMove = 0; // Prevent further movement this frame } } else { // Move towards the target point const moveX = (segmentDx / distanceToTarget) * distanceToMove; const moveY = (segmentDy / distanceToTarget) * distanceToMove; car.x += moveX; car.y += moveY; distanceToMove = 0; // Used up all movement for this frame } } // End while(distanceToMove > 0) // --- Update Stats --- updateCarStats(car, deltaTime); // --- Update Sprite Animation --- car.sprite.setAnimation('drive'); // Could add logic for 'idle' if speed is near zero car.sprite.update(deltaTime); }); // End cars.forEach // --- Update Main Lap Counter --- if (playerLapCompletedThisFrame) { if (playerCarLap >= currentTrackDef.laps) { addRaceMessage(`Race Finished! ${cars[0].name} wins!`, "alert"); pauseRace(); } else { lapCounterElement.textContent = `Lap ${playerCarLap}/${currentTrackDef.laps}`; } } // --- Update UI --- updateStandings(); updateDriverStatusUI(); } /** Update fuel and tyre wear for a car */ function updateCarStats(car: CarState, deltaTime: number) { let fuelDrainRate = 0.1; // Base fuel % drain per second let tyreWearRate = 0.15; // Base tyre % wear per second switch (car.mode) { case 'push': fuelDrainRate = 0.3; tyreWearRate = 0.4; break; case 'conserve': fuelDrainRate = 0.05; tyreWearRate = 0.08; break; } car.fuel = Math.max(0, car.fuel - fuelDrainRate * deltaTime); car.tyreWear = Math.min(100, car.tyreWear + tyreWearRate * deltaTime); // Check for retirement conditions if (car.fuel <= 0 && !car.retired) { car.retired = true; addRaceMessage(`${car.name} ran out of fuel!`, 'warning'); } // Add other retirement conditions (e.g., excessive damage if implemented) } /** Update driver status UI panels */ function updateDriverStatusUI() { driverElements.forEach((ui, index) => { if (!ui) return; const car = cars[index]; // Assumes first two cars are players if (!car) return; // Should not happen if initialized correctly // Find current position based on sorted standings const sortedCars = [...cars].sort((a, b) => { if (a.retired && !b.retired) return 1; if (!a.retired && b.retired) return -1; if (a.retired && b.retired) return 0; if (b.lap !== a.lap) return b.lap - a.lap; // Calculate distance along the path for sorting within the same lap const distA = calculateDistanceAlongPath(a); const distB = calculateDistanceAlongPath(b); return distB - distA; // Higher distance is further ahead }); const currentPosition = sortedCars.findIndex(c => c.id === car.id) + 1; if (ui.pos) ui.pos.textContent = car.retired ? 'RET' : `P${currentPosition}`; const fuelPercent = car.fuel.toFixed(0); if (ui.fuelFill) ui.fuelFill.style.width = `${fuelPercent}%`; if (ui.fuelText) ui.fuelText.textContent = `${fuelPercent}%`; const tyrePercent = car.tyreWear.toFixed(0); if (ui.tyreFill) { ui.tyreFill.style.width = `${tyrePercent}%`; ui.tyreFill.classList.remove('bg-green-500', 'bg-orange-500', 'bg-red-600'); if (car.tyreWear > 85) ui.tyreFill.classList.add('bg-red-600'); else if (car.tyreWear > 60) ui.tyreFill.classList.add('bg-orange-500'); else ui.tyreFill.classList.add('bg-green-500'); } if (ui.tyreText) ui.tyreText.textContent = `${tyrePercent}%`; }); } /** Helper to calculate total distance covered by a car along the path */ function calculateDistanceAlongPath(car: CarState): number { const path = currentTrackDef.path; if (path.length < 2) return 0; let totalDistance = car.lap * calculateTotalPathLength(path); // Distance from completed laps // Distance along current lap segments up to the *start* of the current segment const startIndex = (car.currentPathIndex === 0) ? path.length - 1 : car.currentPathIndex - 1; for (let i = 0; i < startIndex; i++) { // Iterate up to the segment before the car's current target const p1 = path[i]; const p2 = path[(i + 1) % path.length]; totalDistance += Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } // Add distance along the current segment const startPoint = path[startIndex]; const distanceIntoCurrentSegment = Math.sqrt(Math.pow(car.x - startPoint.x, 2) + Math.pow(car.y - startPoint.y, 2)); totalDistance += distanceIntoCurrentSegment; // --- Refined Sort Value --- // Primary sort: Lap number (higher is better) // Secondary sort: Distance covered in current lap (higher is better) // This provides a continuous measure of progress. const currentLapProgress = totalDistance - (car.lap * calculateTotalPathLength(path)); const sortValue = (car.lap * 10000) + currentLapProgress; // Combine lap and progress return sortValue; } /** Helper to calculate total length of the defined path */ function calculateTotalPathLength(path: Point[]): number { let totalLength = 0; if (path.length < 2) return 0; for (let i = 0; i < path.length; i++) { const p1 = path[i]; const p2 = path[(i + 1) % path.length]; // Wrap around for closed loop totalLength += Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } return totalLength; } /** Update the standings list in the UI */ function updateStandings() { if (!standingsList) return; const sortedCars = [...cars].sort((a, b) => { if (a.retired && !b.retired) return 1; if (!a.retired && b.retired) return -1; if (a.retired && b.retired) return 0; // Maintain relative order among retired // Use the calculated distance/sort value for ranking return calculateDistanceAlongPath(b) - calculateDistanceAlongPath(a); }); standingsList.innerHTML = ''; // Clear previous standings sortedCars.forEach((car, index) => { const li = document.createElement('li'); li.className = 'flex justify-between items-center px-1.5 py-0.5 rounded'; li.classList.add(car.team === PLAYER_TEAM_NAME ? 'bg-gray-900' : 'bg-gray-700'); if (car.retired) li.classList.add('opacity-40', 'line-through'); const nameSpan = document.createElement('span'); nameSpan.textContent = `${index + 1}. ${car.name}`; const teamSpan = document.createElement('span'); teamSpan.className = 'text-xxs opacity-80'; // Smaller team name teamSpan.textContent = car.team; li.appendChild(nameSpan); li.appendChild(teamSpan); standingsList.appendChild(li); }); } /** Adds a message to the race log UI */ function addRaceMessage(message: string, type: 'info' | 'warning' | 'alert' | 'player' = 'info') { if (!raceLog) return; const p = document.createElement('p'); const lapNum = cars[0]?.lap ?? 0; // Get player 1's lap for context const displayLap = Math.max(0, Math.min(lapNum, currentTrackDef?.laps ?? 0)); p.innerHTML = `[${displayLap}] ${message}`; // Use innerHTML to allow basic formatting if needed p.classList.remove('text-orange-400', 'text-yellow-400', 'text-blue-300'); if (type === 'warning') p.classList.add('text-orange-400'); else if (type === 'alert') p.classList.add('text-yellow-400'); else if (type === 'player') p.classList.add('text-blue-300'); raceLog.appendChild(p); // Limit log length (optional) while (raceLog.children.length > 50) { raceLog.removeChild(raceLog.firstChild!); } raceLog.scrollTop = raceLog.scrollHeight; // Auto-scroll } // --- Drawing Functions --- /** Clears the canvas */ function clearCanvas() { if (!ctx) return; ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } /** Draws the track background */ function drawBackground() { if (!ctx) return; if (currentTrackBackground && currentTrackBackground.complete && currentTrackBackground.naturalHeight > 0) { ctx.drawImage(currentTrackBackground, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } else { // Fallback drawing ctx.fillStyle = '#334b3a'; // Dark green ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // Draw simple path outline ctx.strokeStyle = '#556b5a'; ctx.lineWidth = 10; ctx.beginPath(); const path = currentTrackDef?.path ?? []; if (path.length > 0) { ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) ctx.lineTo(path[i].x, path[i].y); ctx.closePath(); // Connect back to start ctx.stroke(); } // Draw loading text if background failed if (!currentTrackBackground) { ctx.fillStyle = '#ffffff'; ctx.font = '10px "Press Start 2P"'; ctx.textAlign = 'center'; ctx.fillText("Track BG Load Failed", CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2); ctx.textAlign = 'left'; // Reset alignment } } } /** Draws all active cars */ function drawCars() { if (!ctx) return; // Sort cars by Y position for pseudo-3D effect (lower cars drawn last/on top) const sortedCars = [...cars].sort((a, b) => a.y - b.y); sortedCars.forEach(car => { if (!car.retired) { car.sprite.draw(ctx, car.x, car.y, car.angle); // Fallback drawing if sprite fails if (!car.sprite.isLoaded) { ctx.fillStyle = car.color; ctx.save(); ctx.translate(car.x, car.y); ctx.rotate(car.angle); ctx.fillRect(-6, -3, 12, 6); // Draw a simple rectangle ctx.restore(); } } }); } /** Main drawing function, called each frame */ function draw() { clearCanvas(); drawBackground(); drawCars(); // Draw other elements like HUD overlays directly on canvas if needed } // --- Game Loop Control --- /** Starts the race simulation */ function startRace() { if (isRaceRunning) return; console.log("Starting race..."); isRaceRunning = true; // Reset state before starting resetVisualsAndState(); gameTime = 0; addRaceMessage("Race Started!"); // Enable driver action buttons driverElements.forEach(ui => { ui.actionButtons?.forEach(btn => (btn as HTMLButtonElement).disabled = false); }); // Start the game loop interval if (raceInterval) clearInterval(raceInterval); // Clear any existing interval raceInterval = window.setInterval(() => { update(UPDATE_INTERVAL_MS / 1000); // Pass delta time in seconds }, UPDATE_INTERVAL_MS); // Start the drawing loop if not already running if (!animationFrameId) { animationFrameId = requestAnimationFrame(animationFrame); } // Update button states startButton.disabled = true; pauseButton.disabled = false; retireButton.disabled = false; startButton.classList.add('opacity-50', 'cursor-not-allowed'); pauseButton.classList.remove('opacity-50', 'cursor-not-allowed'); retireButton.classList.remove('opacity-50', 'cursor-not-allowed'); } /** Pauses the race simulation */ function pauseRace() { if (!isRaceRunning) return; console.log("Pausing race..."); isRaceRunning = false; if (raceInterval) clearInterval(raceInterval); raceInterval = null; addRaceMessage("Race Paused."); // Note: We don't cancel the animationFrame loop here, // so the paused state continues to be drawn. // Update button states startButton.disabled = false; pauseButton.disabled = true; startButton.classList.remove('opacity-50', 'cursor-not-allowed'); pauseButton.classList.add('opacity-50', 'cursor-not-allowed'); // Keep retire button active while paused } /** Retires the player's team */ function retirePlayerCars() { addRaceMessage("Player team retired!", "alert"); cars.forEach(car => { if (car.team === PLAYER_TEAM_NAME) car.retired = true; }); if (isRaceRunning) { pauseRace(); // Stop the clock if running } updateStandings(); // Update standings to show retired // Disable controls permanently retireButton.disabled = true; pauseButton.disabled = true; startButton.disabled = true; retireButton.classList.add('opacity-50', 'cursor-not-allowed'); pauseButton.classList.add('opacity-50', 'cursor-not-allowed'); startButton.classList.add('opacity-50', 'cursor-not-allowed'); driverElements.forEach(ui => ui.actionButtons?.forEach(btn => (btn as HTMLButtonElement).disabled = true)); draw(); // Update canvas to show retired cars potentially removed } /** Handle drawing updates using requestAnimationFrame */ function animationFrame() { if (!ctx) { // Check if context exists console.error("Canvas context lost. Stopping animation loop."); if(animationFrameId) cancelAnimationFrame(animationFrameId); animationFrameId = null; return; } draw(); // Continue the loop animationFrameId = requestAnimationFrame(animationFrame); } // --- Event Handlers --- /** Handles track selection change */ function handleTrackChange(event: Event) { const selectElement = event.target as HTMLSelectElement; setTrack(selectElement.value as TrackId); } /** Handles clicks on driver action buttons */ function handleDriverAction(event: Event) { if (!isRaceRunning) return; // Only allow actions during the race const button = event.target as HTMLButtonElement; const driverIndex = parseInt(button.dataset.driver || '0', 10) - 1; // 0 or 1 const action = button.dataset.action as 'push' | 'conserve'; // Only allow these two for now if (action && cars[driverIndex] && !cars[driverIndex].retired) { cars[driverIndex].mode = action; playerDriverModes[driverIndex + 1] = action; // Store player choice addRaceMessage(`${cars[driverIndex].name} mode: ${action.toUpperCase()}.`, 'player'); // Update button visual state driverElements[driverIndex]?.actionButtons?.forEach(btn => { (btn as HTMLButtonElement).classList.remove('opacity-50'); if ((btn as HTMLButtonElement).dataset.action === action) { (btn as HTMLButtonElement).classList.add('opacity-50'); } }); } } // --- Global Initialization --- async function main() { if (!ctx) { console.error("Canvas context not available. Cannot initialize game."); alert("Error: Canvas not supported or enabled!"); return; } console.log("Setting up game..."); // Initial UI setup populateTrackSelector(); ctx.imageSmoothingEnabled = false; // Ensure pixelated look // Load ALL assets needed initially const assetPaths: { [key: string]: string } = { // Use the src as the key for easy lookup later ['./assets/cars_spritesheet.png']: './assets/cars_spritesheet.png' }; Object.values(trackDefinitions).forEach(def => { assetPaths[def.backgroundImageSrc] = def.backgroundImageSrc; }); try { loadedAssets = await loadImages(assetPaths); carSpriteSheet = loadedAssets['./assets/cars_spritesheet.png'] ?? null; // Assign loaded car sheet // Set the initial track (this also initializes cars and resets state) await setTrack(currentTrackId); // Make sure setTrack can handle potentially unloaded assets now // Attach event listeners trackSelect?.addEventListener('change', handleTrackChange); startButton?.addEventListener('click', startRace); pauseButton?.addEventListener('click', pauseRace); retireButton?.addEventListener('click', retirePlayerCars); document.querySelectorAll('.driver-actions button').forEach(button => { button.addEventListener('click', handleDriverAction); }); // Add listener for pit strategy confirmation (placeholder) const confirmStrategyBtn = document.getElementById('confirm-strategy-btn'); if (confirmStrategyBtn) { confirmStrategyBtn.addEventListener('click', () => { const tyre = (document.getElementById('tyre-compound') as HTMLSelectElement)?.value; const lap = (document.getElementById('pit-lap') as HTMLSelectElement)?.value; const fuel = (document.getElementById('refuel') as HTMLSelectElement)?.value; addRaceMessage(`Pit strategy: ${tyre}/${lap}/Fuel:${fuel}`, 'player'); // Add actual logic to store and use this strategy }); } console.log("Game setup complete. Ready to start."); addRaceMessage("Game Ready. Select Track & Start Race!"); // Start the drawing loop to show the initial state if (!animationFrameId) { animationFrameId = requestAnimationFrame(animationFrame); } } catch (error) { console.error("Initialization failed:", error); addRaceMessage("Error loading game assets!", "alert"); } } // --- Start the application --- main(); ```You are absolutely right to point that out! My apologies, the previous fix didn't properly address the flexbox constraints, leading to that broken layout in the screenshot. The comments appearing were also a clear mistake on my part. I've gone back and corrected the issues: 1. **Removed Invalid HTML Comments:** All the `{/* ... */}` comments have been removed from the HTML structure. 2. **Corrected Flexbox/Grid Layout:** * Ensured the main container (`.main-container`) and the `main` grid element are set up as flex columns (`flex`, `flex-col`) and can shrink (`min-height: 0`). * Explicitly made the right column (`lg:col-span-1`) a flex column (`flex`, `flex-col`) so its children stack vertically. * Added `flex-shrink-0` to the Standings and Pit Strategy sections so they don't shrink. * Confirmed the Messages section has `flex-grow` and `min-h-0` so it takes up the remaining space, and `overflow-y-auto` on its inner `div` to enable scrolling. * Added `overflow-y-auto` to the left column as well, just in case its content ever exceeds the available height on smaller screens. Here is the corrected code in the document: ```html Web AutoSport Manager - Canvas Edition

RETROSPORT MANAGER

Season 1 - Race 1: Indianapolis

Team: Red Falcon
Money: $1,250,000

Race Progress

Lap 0/15 Dry

Race Track

Your browser does not support the HTML5 canvas tag.

Race Controls

Driver Status

MAX SPEED P?

100%
0%

SAM RACER P?

100%
0%

Standings

Pit Strategy

Messages

```typescript // === retro_manager_game.ts === // Compile using: tsc retro_manager_game.ts --module esnext --target es6 --outDir . // Creates retro_manager_game.js // --- Interfaces & Types --- interface Point { x: number; y: number; } type TrackId = 'indianapolis' | 'laguna_seca' | 'cota' | 'road_america' | 'talladega' | 'watkins_glen'; interface TrackDefinition { name: string; laps: number; path: Point[]; // Coordinate path for cars backgroundImageSrc: string; // Path to track background image // Add other track-specific data if needed (e.g., pit lane path) } interface SpriteAnimation { frames: number[]; // Array of frame indices on the spritesheet speed: number; // Animation speed in frames per second loop: boolean; } interface SpriteConfig { imageSrc: string; frameWidth: number; frameHeight: number; animations: { [key: string]: SpriteAnimation }; scale?: number; } interface CarState { id: string; // HTML element ID element: HTMLElement | null; // Reference to the (unused) div element if needed later name: string; team: string; color: string; // For drawing placeholder if sprite fails sprite: Sprite; // Sprite instance for this car // Gameplay State positionPercent: number; // Progress along the current path segment (0-1) - Not used in current path logic currentPathIndex: number; // Index of the *target* point in the track path array lap: number; retired: boolean; mode: 'push' | 'conserve' | 'auto'; // Driving mode speedFactorBase: number; // Base speed multiplier fuel: number; // Fuel percentage (0-100) tyreWear: number; // Tyre wear percentage (0-100) // Path Following State x: number; // Current X position on canvas y: number; // Current Y position on canvas angle: number; // Current angle of movement } // --- Asset Loader --- async function loadImages(sources: { [key: string]: string }): Promise<{ [key: string]: HTMLImageElement }> { const images: { [key: string]: HTMLImageElement } = {}; const promises: Promise[] = []; console.log("Loading images:", Object.values(sources)); for (const key in sources) { images[key] = new Image(); promises.push(new Promise((resolve, reject) => { images[key].onload = () => { console.log(`Image loaded: ${sources[key]}`); resolve(); }; images[key].onerror = (err) => { console.error(`Failed to load image: ${sources[key]}`, err); resolve(); }; // Resolve even on error images[key].src = sources[key]; })); } await Promise.all(promises); console.log("Image loading process finished."); return images; } // --- Sprite Class --- class Sprite { image: HTMLImageElement | null = null; isLoaded: boolean = false; frameWidth: number; frameHeight: number; scale: number; animations: { [key: string]: SpriteAnimation }; framesPerRow: number = 0; currentAnimation: string | null = null; currentFrameIndex: number = 0; frameTimer: number = 0; constructor(config: SpriteConfig) { this.frameWidth = config.frameWidth; this.frameHeight = config.frameHeight; this.animations = config.animations; this.scale = config.scale ?? 1; } setImage(image: HTMLImageElement) { this.image = image; this.isLoaded = image.complete && image.naturalHeight !== 0; // Check if actually loaded if (this.isLoaded) { this.framesPerRow = Math.floor(this.image.width / this.frameWidth); if (!this.currentAnimation && Object.keys(this.animations).length > 0) { this.setAnimation(Object.keys(this.animations)[0]); } } else { console.warn(`Image ${image.src} not fully loaded when setting sprite.`); // Attempt to set loaded status on image load event this.image.onload = () => { console.log(`Image ${image.src} loaded after assignment.`); this.isLoaded = true; this.framesPerRow = Math.floor(this.image.width / this.frameWidth); if (!this.currentAnimation && Object.keys(this.animations).length > 0) { this.setAnimation(Object.keys(this.animations)[0]); } } } } setAnimation(name: string) { if (this.currentAnimation === name || !this.animations[name]) return; this.currentAnimation = name; this.currentFrameIndex = 0; this.frameTimer = 0; } update(deltaTime: number) { if (!this.isLoaded || !this.currentAnimation) return; const anim = this.animations[this.currentAnimation]; if (!anim || anim.frames.length <= 1) return; this.frameTimer += deltaTime; const timePerFrame = 1 / anim.speed; while (this.frameTimer >= timePerFrame) { this.frameTimer -= timePerFrame; this.currentFrameIndex++; if (this.currentFrameIndex >= anim.frames.length) { this.currentFrameIndex = anim.loop ? 0 : anim.frames.length - 1; } } } draw(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number = 0) { if (!this.isLoaded || !this.image || !this.currentAnimation) return; const anim = this.animations[this.currentAnimation]; if (!anim) return; const frameNumber = anim.frames[this.currentFrameIndex]; const frameCol = frameNumber % this.framesPerRow; const frameRow = Math.floor(frameNumber / this.framesPerRow); const sx = frameCol * this.frameWidth; const sy = frameRow * this.frameHeight; const drawWidth = this.frameWidth * this.scale; const drawHeight = this.frameHeight * this.scale; const drawX = -drawWidth / 2; // Draw relative to center for rotation const drawY = -drawHeight / 2; ctx.save(); ctx.translate(Math.floor(x), Math.floor(y)); // Move origin to car's position ctx.rotate(angle); // Rotate around the new origin ctx.drawImage( this.image, sx, sy, this.frameWidth, this.frameHeight, drawX, drawY, drawWidth, drawHeight ); ctx.restore(); // Restore original transform state } } // --- Constants & Configuration --- const CANVAS_WIDTH = 320; const CANVAS_HEIGHT = 240; const PLAYER_TEAM_NAME = "Red Falcon"; // Used for highlighting standings const UPDATE_INTERVAL_MS = 50; // Update game state more frequently (20 FPS logic) const PIXELS_PER_METER = 2; // Example scaling factor for speed calculations // --- Track Definitions --- // Define paths relative to the 320x240 canvas const trackDefinitions: Record = { indianapolis: { name: "Indianapolis", laps: 15, backgroundImageSrc: './assets/track_indy.png', path: [ { x: 40, y: 120 }, { x: 80, y: 60 }, { x: 240, y: 60 }, { x: 280, y: 120 }, { x: 240, y: 180 }, { x: 80, y: 180 }, { x: 40, y: 120 } ] // Closed loop implicitly }, laguna_seca: { name: "Laguna Seca", laps: 12, backgroundImageSrc: './assets/track_laguna.png', // Placeholder path: [ { x: 40, y: 200 }, { x: 250, y: 200 }, { x: 280, y: 170 }, { x: 260, y: 140 }, { x: 220, y: 150 }, { x: 180, y: 120 }, { x: 190, y: 80 }, { x: 160, y: 50 }, { x: 120, y: 50 }, { x: 100, y: 80 }, { x: 120, y: 120 }, { x: 80, y: 160 }, { x: 40, y: 200 } ] }, cota: { name: "COTA", laps: 10, backgroundImageSrc: './assets/track_cota.png', // Placeholder path: [ { x: 40, y: 200 }, { x: 200, y: 200 }, { x: 280, y: 150 }, { x: 280, y: 80 }, { x: 240, y: 40 }, { x: 180, y: 40 }, { x: 150, y: 70 }, { x: 160, y: 100 }, { x: 140, y: 130 }, { x: 100, y: 120 }, { x: 80, y: 150 }, { x: 80, y: 180 }, { x: 40, y: 200 } ] }, road_america: { name: "Road America", laps: 8, backgroundImageSrc: './assets/track_roadamerica.png', // Placeholder path: [ { x: 40, y: 180 }, { x: 280, y: 180 }, { x: 300, y: 150 }, { x: 280, y: 120 }, { x: 240, y: 140 }, { x: 200, y: 100 }, { x: 180, y: 60 }, { x: 120, y: 40 }, { x: 80, y: 60 }, { x: 60, y: 100 }, { x: 80, y: 140 }, { x: 40, y: 180 } ] }, talladega: { name: "Talladega", laps: 18, backgroundImageSrc: './assets/track_talladega.png', // Placeholder path: [ { x: 40, y: 120 }, { x: 90, y: 50 }, { x: 230, y: 50 }, { x: 280, y: 120 }, { x: 230, y: 190 }, { x: 90, y: 190 }, { x: 40, y: 120 } ] // Wider oval }, watkins_glen: { name: "Watkins Glen", laps: 10, backgroundImageSrc: './assets/track_watkins.png', // Placeholder path: [ { x: 40, y: 190 }, { x: 180, y: 190 }, { x: 220, y: 160 }, { x: 200, y: 130 }, { x: 220, y: 100 }, { x: 260, y: 100 }, { x: 280, y: 70 }, { x: 260, y: 40 }, { x: 180, y: 40 }, { x: 140, y: 70 }, { x: 160, y: 110 }, { x: 120, y: 150 }, { x: 60, y: 150 }, { x: 40, y: 190 } ] } }; // --- Game State Variables --- let currentTrackId: TrackId = 'indianapolis'; let cars: CarState[] = []; let loadedAssets: { [key: string]: HTMLImageElement } = {}; let carSpriteSheet: HTMLImageElement | null = null; let currentTrackDef: TrackDefinition = trackDefinitions[currentTrackId]; let currentTrackBackground: HTMLImageElement | null = null; let raceInterval: number | null = null; let animationFrameId: number | null = null; // Store requestAnimationFrame ID let isRaceRunning: boolean = false; let playerDriverModes: { [key: number]: 'push' | 'conserve' } = { 1: 'conserve', 2: 'conserve' }; let gameTime = 0; // Simple game time counter // --- DOM Element References --- const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d'); const trackSelect = document.getElementById('track-select') as HTMLSelectElement; const raceIdentifierElement = document.getElementById('race-identifier') as HTMLElement; const lapCounterElement = document.getElementById('lap-counter') as HTMLElement; const weatherStatusElement = document.getElementById('weather-status') as HTMLElement; // Placeholder const startButton = document.getElementById('start-btn') as HTMLButtonElement; const pauseButton = document.getElementById('pause-btn') as HTMLButtonElement; const retireButton = document.getElementById('retire-btn') as HTMLButtonElement; const raceLog = document.getElementById('race-log') as HTMLElement; const standingsList = document.getElementById('standings-list') as HTMLElement; const driverElements = [ // Assuming max 2 player drivers { fuelFill: document.getElementById('driver1-fuel-fill'), fuelText: document.getElementById('driver1-fuel-text'), tyreFill: document.getElementById('driver1-tyre-fill'), tyreText: document.getElementById('driver1-tyre-text'), pos: document.getElementById('driver1-pos'), actionButtons: document.querySelectorAll('.driver-actions button[data-driver="1"]') }, { fuelFill: document.getElementById('driver2-fuel-fill'), fuelText: document.getElementById('driver2-fuel-text'), tyreFill: document.getElementById('driver2-tyre-fill'), tyreText: document.getElementById('driver2-tyre-text'), pos: document.getElementById('driver2-pos'), actionButtons: document.querySelectorAll('.driver-actions button[data-driver="2"]') } ]; // --- Initialization Functions --- /** Populates the track selector dropdown */ function populateTrackSelector() { if (!trackSelect) return; // Clear existing options first trackSelect.innerHTML = ''; Object.keys(trackDefinitions).forEach(trackId => { const option = document.createElement('option'); option.value = trackId; option.textContent = trackDefinitions[trackId as TrackId].name; trackSelect.appendChild(option); }); trackSelect.value = currentTrackId; } /** Creates the initial car data structures */ function initializeCarData() { const carBaseConfig: Omit = { id: '', element: null, name: '', team: '', color: '', retired: false, mode: 'auto', speedFactorBase: 1.0, fuel: 100, tyreWear: 0 }; // Define car roster const roster = [ { ...carBaseConfig, id: 'car1', name: 'MAX SPEED', team: PLAYER_TEAM_NAME, color: '#ff0000', mode: playerDriverModes[1] }, { ...carBaseConfig, id: 'car2', name: 'SAM RACER', team: PLAYER_TEAM_NAME, color: '#dc2626', mode: playerDriverModes[2] }, { ...carBaseConfig, id: 'car3', name: 'CHRIS FAST', team: 'Yellow Storm', color: '#ffff00' }, { ...carBaseConfig, id: 'car4', name: 'DANNY ZOOM', team: 'Green Arrow', color: '#00ff00' }, { ...carBaseConfig, id: 'car5', name: 'ALEX TURBO', team: 'Blue Light', color: '#0088ff' }, // Add more cars up to 8 if needed ]; // Define sprite config (assuming one sheet for all cars for now) const carSpriteDefinition: SpriteConfig = { imageSrc: './assets/cars_spritesheet.png', // Will be replaced by loaded asset frameWidth: 16, frameHeight: 16, scale: 1.5, animations: { idle: { frames: [0], speed: 1, loop: false }, // Frame 0 = default/idle drive: { frames: [0, 1], speed: 10, loop: true }, // Example driving animation // Add turn_left, turn_right etc. based on your sheet } }; cars = roster.map((cfg, index) => { const sprite = new Sprite(carSpriteDefinition); if (carSpriteSheet) sprite.setImage(carSpriteSheet); // Assign preloaded sheet const startOffset = index * 3; // Stagger start position along path // Ensure path exists before accessing it const path = currentTrackDef?.path ?? []; const startPathIndex = path.length > 0 ? startOffset % path.length : 0; const startPoint = path[startPathIndex]; return { ...cfg, sprite: sprite, x: startPoint?.x ?? 50, // Default position if path is empty y: startPoint?.y ?? 50, angle: 0, currentPathIndex: startPathIndex, positionPercent: 0, lap: 0, speedFactorBase: 18 + Math.random() * 4, // Base speed in canvas units/sec }; }); console.log("Car data initialized:", cars.length); } /** Sets the current track, loads background, resets state */ async function setTrack(trackId: TrackId) { if (!trackDefinitions[trackId]) return; const wasRunning = isRaceRunning; if (wasRunning) pauseRace(); currentTrackId = trackId; currentTrackDef = trackDefinitions[trackId]; console.log(`Setting track to: ${currentTrackDef.name}`); // Update UI Text raceIdentifierElement.textContent = `Season 1 - Race 1: ${currentTrackDef.name}`; lapCounterElement.textContent = `Lap 0/${currentTrackDef.laps}`; // Load new background image - Ensure it's loaded before proceeding const bgKey = currentTrackDef.backgroundImageSrc; // Use the src as the key currentTrackBackground = loadedAssets[bgKey] ?? null; if (!currentTrackBackground || !currentTrackBackground.complete || currentTrackBackground.naturalHeight === 0) { console.warn(`Background image for ${currentTrackDef.name} not pre-loaded or invalid. Attempting load now.`); try { const singleImage = await loadImages({ bg: bgKey }); // Load just this one currentTrackBackground = singleImage.bg; if (!currentTrackBackground || !currentTrackBackground.complete || currentTrackBackground.naturalHeight === 0) { console.error(`Failed to load background for ${currentTrackDef.name}.`); currentTrackBackground = null; } else { loadedAssets[bgKey] = currentTrackBackground; // Store if successful console.log(`Successfully loaded background for ${currentTrackDef.name} on demand.`); } } catch (err) { console.error(`Error loading background for ${currentTrackDef.name}:`, err); currentTrackBackground = null; } } else { console.log(`Using pre-loaded background for ${currentTrackDef.name}`); } // Clear log and add track set message if (raceLog) raceLog.innerHTML = ''; addRaceMessage(`Track set to ${currentTrackDef.name}. ${currentTrackDef.laps} laps.`); // Reset car data and positions for the new track path initializeCarData(); // Re-create car state objects resetVisualsAndState(); // Reset positions, UI, etc. // Reset button states startButton.disabled = false; retireButton.disabled = false; pauseButton.disabled = true; startButton.classList.remove('opacity-50', 'cursor-not-allowed'); retireButton.classList.remove('opacity-50', 'cursor-not-allowed'); pauseButton.classList.add('opacity-50', 'cursor-not-allowed'); document.querySelectorAll('.driver-actions button').forEach(btn => (btn as HTMLButtonElement).disabled = true); // Disable actions until race starts // Immediately draw the new state (background + reset cars) if (!animationFrameId) { // Start drawing loop if not already running requestAnimationFrame(animationFrame); } else { draw(); // Otherwise, just draw the current state } } /** Resets car positions, UI elements, and game state */ function resetVisualsAndState() { // Reset car positions based on the current path const path = currentTrackDef?.path ?? []; cars.forEach((car, index) => { const startOffset = index * 3; const startPathIndex = path.length > 0 ? startOffset % path.length : 0; const startPoint = path[startPathIndex]; car.currentPathIndex = startPathIndex; car.positionPercent = 0; car.x = startPoint?.x ?? 50; car.y = startPoint?.y ?? 50; car.lap = 0; car.retired = false; car.fuel = 100; car.tyreWear = 0; car.angle = 0; // Reset angle // Recalculate initial angle based on first segment if (path.length > 1) { const nextIndex = (startPathIndex + 1) % path.length; const nextPoint = path[nextIndex]; car.angle = Math.atan2(nextPoint.y - car.y, nextPoint.x - car.x); } car.mode = (index < 2) ? playerDriverModes[index + 1] : 'auto'; car.sprite.setAnimation('idle'); // Reset sprite animation }); console.log("Car visual and state reset."); // Reset UI lapCounterElement.textContent = `Lap 0/${currentTrackDef.laps}`; updateStandings(); resetDriverStatusUI(); } /** Resets driver status UI panels */ function resetDriverStatusUI() { driverElements.forEach((ui, index) => { if (!ui) return; const driverIndex = index + 1; // 1 or 2 if (ui.pos) ui.pos.textContent = `P?`; if (ui.fuelFill) ui.fuelFill.style.width = `100%`; if (ui.fuelText) ui.fuelText.textContent = `100%`; if (ui.tyreFill) { ui.tyreFill.style.width = `0%`; ui.tyreFill.classList.remove('bg-orange-500', 'bg-red-600'); ui.tyreFill.classList.add('bg-green-500'); } if (ui.tyreText) ui.tyreText.textContent = `0%`; // Reset button visual state ui.actionButtons?.forEach(button => { (button as HTMLButtonElement).classList.remove('opacity-50'); if ((button as HTMLButtonElement).dataset.action === playerDriverModes[driverIndex]) { (button as HTMLButtonElement).classList.add('opacity-50'); } (button as HTMLButtonElement).disabled = true; // Disable until race starts }); }); } // --- Update Functions --- /** Main game state update logic */ function update(deltaTime: number) { if (!isRaceRunning) return; gameTime += deltaTime; let playerLapCompletedThisFrame = false; // Track if player 1 finished a lap *this frame* let playerCarLap = cars[0]?.lap ?? 0; // Track player 1's lap for main counter cars.forEach((car, index) => { if (car.retired) return; const prevLap = car.lap; // --- Path Following --- const path = currentTrackDef.path; if (path.length < 2) return; // Need a path let targetIndex = car.currentPathIndex; // Ensure targetIndex is valid if (targetIndex < 0 || targetIndex >= path.length) { console.error(`Invalid targetIndex ${targetIndex} for car ${car.id}. Resetting to 0.`); targetIndex = 0; car.currentPathIndex = 0; } let startPointIndex = (targetIndex === 0) ? path.length - 1 : targetIndex - 1; let startPoint = path[startPointIndex]; let targetPoint = path[targetIndex]; // --- Calculate Speed --- let currentSpeed = car.speedFactorBase; // Base speed in canvas units/sec switch(car.mode) { case 'push': currentSpeed *= 1.2; break; case 'conserve': currentSpeed *= 0.8; break; } currentSpeed *= (1.0 - (car.tyreWear / 250)); // Reduce speed slightly with tyre wear (less harsh) currentSpeed = Math.max(5, currentSpeed); // Minimum speed // --- Update Position along Path --- let distanceToMove = currentSpeed * deltaTime; // Move car potentially across multiple segments in one frame while (distanceToMove > 0 && path.length > 1) { // Add check for path length const segmentDx = targetPoint.x - car.x; const segmentDy = targetPoint.y - car.y; const distanceToTarget = Math.sqrt(segmentDx * segmentDx + segmentDy * segmentDy); // Update angle based on current segment direction if (distanceToTarget > 0.1) { // Avoid calculating angle for tiny distances car.angle = Math.atan2(segmentDy, segmentDx); } if (distanceToTarget <= distanceToMove || distanceToTarget < 0.01) { // Also advance if very close // Reached or passed the target point distanceToMove -= distanceToTarget; // Subtract distance covered car.x = targetPoint.x; car.y = targetPoint.y; car.currentPathIndex = (car.currentPathIndex + 1) % path.length; // Move to next target index // Check for crossing the start/finish line (when target becomes index 0 AFTER advancing) if (car.currentPathIndex === 0) { car.lap++; // If this is player 1, update the main lap counter if (index === 0) { playerCarLap = car.lap; // Update tracked lap playerLapCompletedThisFrame = true; // Signal that the main counter needs update } } // Update start/target points for the next iteration targetIndex = car.currentPathIndex; targetPoint = path[targetIndex]; startPointIndex = (targetIndex === 0) ? path.length - 1 : targetIndex - 1; startPoint = path[startPointIndex]; // Safety break for potential infinite loops on very short segments / high speeds if (distanceToMove > 0 && distanceToTarget < 0.01) { // console.warn(`Breaking move loop due to minimal distance for car ${car.id}`); distanceToMove = 0; } } else { // Move towards the target point const moveX = (segmentDx / distanceToTarget) * distanceToMove; const moveY = (segmentDy / distanceToTarget) * distanceToMove; car.x += moveX; car.y += moveY; distanceToMove = 0; // Used up all movement for this frame } } // End while(distanceToMove > 0) // --- Update Stats --- updateCarStats(car, deltaTime); // --- Update Sprite Animation --- car.sprite.setAnimation('drive'); // Could add logic for 'idle' if speed is near zero car.sprite.update(deltaTime); }); // End cars.forEach // --- Update Main Lap Counter --- if (playerLapCompletedThisFrame) { if (playerCarLap >= currentTrackDef.laps) { addRaceMessage(`Race Finished! ${cars[0]?.name ?? 'Player 1'} wins!`, "alert"); // Added null check pauseRace(); } else { lapCounterElement.textContent = `Lap ${playerCarLap}/${currentTrackDef.laps}`; } } // --- Update UI --- updateStandings(); updateDriverStatusUI(); } /** Update fuel and tyre wear for a car */ function updateCarStats(car: CarState, deltaTime: number) { let fuelDrainRate = 0.1; // Base fuel % drain per second let tyreWearRate = 0.15; // Base tyre % wear per second switch (car.mode) { case 'push': fuelDrainRate = 0.3; tyreWearRate = 0.4; break; case 'conserve': fuelDrainRate = 0.05; tyreWearRate = 0.08; break; } car.fuel = Math.max(0, car.fuel - fuelDrainRate * deltaTime); car.tyreWear = Math.min(100, car.tyreWear + tyreWearRate * deltaTime); // Check for retirement conditions if (car.fuel <= 0 && !car.retired) { car.retired = true; addRaceMessage(`${car.name} ran out of fuel!`, 'warning'); } // Add other retirement conditions (e.g., excessive damage if implemented) } /** Update driver status UI panels */ function updateDriverStatusUI() { driverElements.forEach((ui, index) => { if (!ui) return; const car = cars[index]; // Assumes first two cars are players if (!car) return; // Should not happen if initialized correctly // Find current position based on sorted standings const sortedCars = [...cars].sort((a, b) => { if (a.retired && !b.retired) return 1; if (!a.retired && b.retired) return -1; if (a.retired && b.retired) return 0; if (b.lap !== a.lap) return b.lap - a.lap; // Use the helper function for consistent sorting return calculateDistanceSortValue(b) - calculateDistanceSortValue(a); }); const currentPosition = sortedCars.findIndex(c => c.id === car.id) + 1; if (ui.pos) ui.pos.textContent = car.retired ? 'RET' : `P${currentPosition}`; const fuelPercent = car.fuel.toFixed(0); if (ui.fuelFill) ui.fuelFill.style.width = `${fuelPercent}%`; if (ui.fuelText) ui.fuelText.textContent = `${fuelPercent}%`; const tyrePercent = car.tyreWear.toFixed(0); if (ui.tyreFill) { ui.tyreFill.style.width = `${tyrePercent}%`; ui.tyreFill.classList.remove('bg-green-500', 'bg-orange-500', 'bg-red-600'); if (car.tyreWear > 85) ui.tyreFill.classList.add('bg-red-600'); else if (car.tyreWear > 60) ui.tyreFill.classList.add('bg-orange-500'); else ui.tyreFill.classList.add('bg-green-500'); } if (ui.tyreText) ui.tyreText.textContent = `${tyrePercent}%`; }); } /** Helper function to get a sortable value representing car's progress */ function calculateDistanceSortValue(car: CarState): number { const path = currentTrackDef?.path ?? []; if (path.length < 2) return car.lap * -10000; // Penalize if no path // Calculate distance to the *next* target node for fine sorting within the same lap/segment const targetIndex = car.currentPathIndex; const targetPoint = path[targetIndex]; const dx = targetPoint.x - car.x; const dy = targetPoint.y - car.y; const distanceToNextTarget = Math.sqrt(dx*dx + dy*dy); // Sort value: Higher lap is better. Lower target index means further along the lap. Lower distance to target is better. // Combine these into a single sort value (larger is better) const sortValue = (car.lap * 100000) + ((path.length - targetIndex) * 1000) + (1000 - distanceToNextTarget); return sortValue; } /** Helper to calculate total length of the defined path */ function calculateTotalPathLength(path: Point[]): number { let totalLength = 0; if (path.length < 2) return 0; for (let i = 0; i < path.length; i++) { const p1 = path[i]; const p2 = path[(i + 1) % path.length]; // Wrap around for closed loop totalLength += Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } return totalLength; } /** Update the standings list in the UI */ function updateStandings() { if (!standingsList) return; const sortedCars = [...cars].sort((a, b) => { if (a.retired && !b.retired) return 1; if (!a.retired && b.retired) return -1; if (a.retired && b.retired) return 0; // Maintain relative order among retired // Use the calculated distance/sort value for ranking return calculateDistanceSortValue(b) - calculateDistanceSortValue(a); }); standingsList.innerHTML = ''; // Clear previous standings sortedCars.forEach((car, index) => { const li = document.createElement('li'); li.className = 'flex justify-between items-center px-1.5 py-0.5 rounded'; li.classList.add(car.team === PLAYER_TEAM_NAME ? 'bg-gray-900' : 'bg-gray-700'); if (car.retired) li.classList.add('opacity-40', 'line-through'); const nameSpan = document.createElement('span'); nameSpan.textContent = `${index + 1}. ${car.name}`; const teamSpan = document.createElement('span'); teamSpan.className = 'text-xxs opacity-80'; // Smaller team name teamSpan.textContent = car.team; li.appendChild(nameSpan); li.appendChild(teamSpan); standingsList.appendChild(li); }); } /** Adds a message to the race log UI */ function addRaceMessage(message: string, type: 'info' | 'warning' | 'alert' | 'player' = 'info') { if (!raceLog) return; const p = document.createElement('p'); const lapNum = cars[0]?.lap ?? 0; // Get player 1's lap for context const displayLap = Math.max(0, Math.min(lapNum, currentTrackDef?.laps ?? 0)); // Create text nodes to prevent potential XSS if message content is ever dynamic const lapText = document.createTextNode(`[${displayLap}] `); const messageText = document.createTextNode(message); p.appendChild(lapText); p.appendChild(messageText); p.classList.remove('text-orange-400', 'text-yellow-400', 'text-blue-300'); if (type === 'warning') p.classList.add('text-orange-400'); else if (type === 'alert') p.classList.add('text-yellow-400'); else if (type === 'player') p.classList.add('text-blue-300'); raceLog.appendChild(p); // Limit log length (optional) while (raceLog.children.length > 50) { raceLog.removeChild(raceLog.firstChild!); } raceLog.scrollTop = raceLog.scrollHeight; // Auto-scroll } // --- Drawing Functions --- /** Clears the canvas */ function clearCanvas() { if (!ctx) return; ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } /** Draws the track background */ function drawBackground() { if (!ctx) return; if (currentTrackBackground && currentTrackBackground.complete && currentTrackBackground.naturalHeight > 0) { ctx.drawImage(currentTrackBackground, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } else { // Fallback drawing ctx.fillStyle = '#334b3a'; // Dark green ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // Draw simple path outline ctx.strokeStyle = '#556b5a'; ctx.lineWidth = 10; ctx.beginPath(); const path = currentTrackDef?.path ?? []; if (path.length > 0) { ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) ctx.lineTo(path[i].x, path[i].y); ctx.closePath(); // Connect back to start ctx.stroke(); } // Draw loading text if background failed if (!currentTrackBackground) { ctx.fillStyle = '#ffffff'; ctx.font = '10px "Press Start 2P"'; ctx.textAlign = 'center'; ctx.fillText("Track BG Load Failed", CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2); ctx.textAlign = 'left'; // Reset alignment } } } /** Draws all active cars */ function drawCars() { if (!ctx) return; // Sort cars by Y position for pseudo-3D effect (lower cars drawn last/on top) const sortedCars = [...cars].sort((a, b) => a.y - b.y); sortedCars.forEach(car => { if (!car.retired) { car.sprite.draw(ctx, car.x, car.y, car.angle); // Fallback drawing if sprite fails if (!car.sprite.isLoaded) { ctx.fillStyle = car.color; ctx.save(); ctx.translate(car.x, car.y); ctx.rotate(car.angle); ctx.fillRect(-6, -3, 12, 6); // Draw a simple rectangle ctx.restore(); } } }); } /** Main drawing function, called each frame */ function draw() { clearCanvas(); drawBackground(); drawCars(); // Draw other elements like HUD overlays directly on canvas if needed } // --- Game Loop Control --- /** Starts the race simulation */ function startRace() { if (isRaceRunning) return; console.log("Starting race..."); isRaceRunning = true; // Reset state before starting resetVisualsAndState(); gameTime = 0; addRaceMessage("Race Started!"); // Enable driver action buttons driverElements.forEach(ui => { ui.actionButtons?.forEach(btn => (btn as HTMLButtonElement).disabled = false); }); // Start the game loop interval if (raceInterval) clearInterval(raceInterval); // Clear any existing interval raceInterval = window.setInterval(() => { update(UPDATE_INTERVAL_MS / 1000); // Pass delta time in seconds }, UPDATE_INTERVAL_MS); // Start the drawing loop if not already running if (!animationFrameId) { animationFrameId = requestAnimationFrame(animationFrame); } // Update button states startButton.disabled = true; pauseButton.disabled = false; retireButton.disabled = false; startButton.classList.add('opacity-50', 'cursor-not-allowed'); pauseButton.classList.remove('opacity-50', 'cursor-not-allowed'); retireButton.classList.remove('opacity-50', 'cursor-not-allowed'); } /** Pauses the race simulation */ function pauseRace() { if (!isRaceRunning) return; console.log("Pausing race..."); isRaceRunning = false; if (raceInterval) clearInterval(raceInterval); raceInterval = null; addRaceMessage("Race Paused."); // Note: We don't cancel the animationFrame loop here, // so the paused state continues to be drawn. // Update button states startButton.disabled = false; pauseButton.disabled = true; startButton.classList.remove('opacity-50', 'cursor-not-allowed'); pauseButton.classList.add('opacity-50', 'cursor-not-allowed'); // Keep retire button active while paused } /** Retires the player's team */ function retirePlayerCars() { addRaceMessage("Player team retired!", "alert"); cars.forEach(car => { if (car.team === PLAYER_TEAM_NAME) car.retired = true; }); if (isRaceRunning) { pauseRace(); // Stop the clock if running } updateStandings(); // Update standings to show retired // Disable controls permanently retireButton.disabled = true; pauseButton.disabled = true; startButton.disabled = true; retireButton.classList.add('opacity-50', 'cursor-not-allowed'); pauseButton.classList.add('opacity-50', 'cursor-not-allowed'); startButton.classList.add('opacity-50', 'cursor-not-allowed'); driverElements.forEach(ui => ui.actionButtons?.forEach(btn => (btn as HTMLButtonElement).disabled = true)); draw(); // Update canvas to show retired cars potentially removed } /** Handle drawing updates using requestAnimationFrame */ function animationFrame() { if (!ctx) { // Check if context exists console.error("Canvas context lost. Stopping animation loop."); if(animationFrameId) cancelAnimationFrame(animationFrameId); animationFrameId = null; return; } draw(); // Continue the loop animationFrameId = requestAnimationFrame(animationFrame); } // --- Event Handlers --- /** Handles track selection change */ function handleTrackChange(event: Event) { const selectElement = event.target as HTMLSelectElement; setTrack(selectElement.value as TrackId); } /** Handles clicks on driver action buttons */ function handleDriverAction(event: Event) { if (!isRaceRunning) return; // Only allow actions during the race const button = event.target as HTMLButtonElement; const driverIndex = parseInt(button.dataset.driver || '0', 10) - 1; // 0 or 1 const action = button.dataset.action as 'push' | 'conserve'; // Only allow these two for now if (action && cars[driverIndex] && !cars[driverIndex].retired) { cars[driverIndex].mode = action; playerDriverModes[driverIndex + 1] = action; // Store player choice addRaceMessage(`${cars[driverIndex].name} mode: ${action.toUpperCase()}.`, 'player'); // Update button visual state driverElements[driverIndex]?.actionButtons?.forEach(btn => { (btn as HTMLButtonElement).classList.remove('opacity-50'); if ((btn as HTMLButtonElement).dataset.action === action) { (btn as HTMLButtonElement).classList.add('opacity-50'); } }); } } // --- Global Initialization --- async function main() { if (!ctx) { console.error("Canvas context not available. Cannot initialize game."); alert("Error: Canvas not supported or enabled!"); return; } console.log("Setting up game..."); // Initial UI setup populateTrackSelector(); ctx.imageSmoothingEnabled = false; // Ensure pixelated look // Load ALL assets needed initially const assetPaths: { [key: string]: string } = { // Use the src as the key for easy lookup later ['./assets/cars_spritesheet.png']: './assets/cars_spritesheet.png' }; Object.values(trackDefinitions).forEach(def => { assetPaths[def.backgroundImageSrc] = def.backgroundImageSrc; }); try { loadedAssets = await loadImages(assetPaths); carSpriteSheet = loadedAssets['./assets/cars_spritesheet.png'] ?? null; // Assign loaded car sheet // Set the initial track (this also initializes cars and resets state) await setTrack(currentTrackId); // Make sure setTrack can handle potentially unloaded assets now // Attach event listeners trackSelect?.addEventListener('change', handleTrackChange); startButton?.addEventListener('click', startRace); pauseButton?.addEventListener('click', pauseRace); retireButton?.addEventListener('click', retirePlayerCars); document.querySelectorAll('.driver-actions button').forEach(button => { button.addEventListener('click', handleDriverAction); }); // Add listener for pit strategy confirmation (placeholder) const confirmStrategyBtn = document.getElementById('confirm-strategy-btn'); if (confirmStrategyBtn) { confirmStrategyBtn.addEventListener('click', () => { const tyre = (document.getElementById('tyre-compound') as HTMLSelectElement)?.value; const lap = (document.getElementById('pit-lap') as HTMLSelectElement)?.value; const fuel = (document.getElementById('refuel') as HTMLSelectElement)?.value; addRaceMessage(`Pit strategy: ${tyre}/${lap}/Fuel:${fuel}`, 'player'); // Add actual logic to store and use this strategy }); } console.log("Game setup complete. Ready to start."); addRaceMessage("Game Ready. Select Track & Start Race!"); // Start the drawing loop to show the initial state if (!animationFrameId) { animationFrameId = requestAnimationFrame(animationFrame); } } catch (error) { console.error("Initialization failed:", error); addRaceMessage("Error loading game assets!", "alert"); } } // --- Start the application --- main();