// main.cpp - Application orchestration (initialization, loop, UI states) // High-level only: delegates Tetris logic, scores, background, font rendering. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "audio/Audio.h" #include "audio/SoundEffect.h" #include "gameplay/core/Game.h" #include "persistence/Scores.h" #include "graphics/effects/Starfield.h" #include "graphics/effects/Starfield3D.h" #include "graphics/effects/SpaceWarp.h" #include "graphics/ui/Font.h" #include "graphics/ui/HelpOverlay.h" #include "gameplay/effects/LineEffect.h" #include "states/State.h" #include "states/LoadingState.h" #include "states/MenuState.h" #include "states/OptionsState.h" #include "states/LevelSelectorState.h" #include "states/PlayingState.h" #include "audio/MenuWrappers.h" #include "app/AssetLoader.h" #include "states/LoadingManager.h" #include "utils/ImagePathResolver.h" #include "graphics/renderers/GameRenderer.h" #include "core/Config.h" #include "core/Settings.h" #include "ui/MenuLayout.h" // Debug logging removed: no-op in this build (previously LOG_DEBUG) // Font rendering now handled by FontAtlas // ---------- Game config ---------- static constexpr int LOGICAL_W = 1200; static constexpr int LOGICAL_H = 1000; static constexpr int WELL_W = Game::COLS * Game::TILE; static constexpr int WELL_H = Game::ROWS * Game::TILE; #include "ui/UIConstants.h" // Piece types now declared in Game.h // Scores now managed by ScoreManager // 4x4 shapes encoded as 16-bit bitmasks per rotation (row-major 4x4). // Bit 0 = (x=0,y=0), Bit 1 = (1,0) ... Bit 15 = (3,3) // Shapes & game logic now in Game.cpp // (removed inline shapes) // Piece struct now in Game.h // Game struct replaced by Game class static const std::array COLORS = {{ SDL_Color{20, 20, 26, 255}, // 0 empty SDL_Color{0, 255, 255, 255}, // I SDL_Color{255, 255, 0, 255}, // O SDL_Color{160, 0, 255, 255}, // T SDL_Color{0, 255, 0, 255}, // S SDL_Color{255, 0, 0, 255}, // Z SDL_Color{0, 0, 255, 255}, // J SDL_Color{255, 160, 0, 255}, // L }}; // Global collector for asset loading errors shown on the loading screen static std::vector g_assetLoadErrors; static std::mutex g_assetLoadErrorsMutex; // Loading counters for progress UI and debug overlay static std::atomic g_totalLoadingTasks{0}; static std::atomic g_loadedTasks{0}; static std::string g_currentLoadingFile; static std::mutex g_currentLoadingMutex; static void drawRect(SDL_Renderer *r, float x, float y, float w, float h, SDL_Color c) { SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a); SDL_FRect fr{x, y, w, h}; SDL_RenderFillRect(r, &fr); } static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) { if (!renderer) { return nullptr; } const std::string resolvedPath = AssetPath::resolveImagePath(path); { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile = resolvedPath.empty() ? path : resolvedPath; } SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); if (!surface) { // Record the error for display on the loading screen { std::lock_guard lk(g_assetLoadErrorsMutex); std::ostringstream ss; ss << "Image load failed: " << path << " (" << resolvedPath << "): " << SDL_GetError(); g_assetLoadErrors.emplace_back(ss.str()); } g_loadedTasks.fetch_add(1); { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile.clear(); } SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError()); return nullptr; } if (outW) { *outW = surface->w; } if (outH) { *outH = surface->h; } SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_DestroySurface(surface); if (!texture) { { std::lock_guard lk(g_assetLoadErrorsMutex); std::ostringstream ss; ss << "Texture create failed: " << resolvedPath << ": " << SDL_GetError(); g_assetLoadErrors.emplace_back(ss.str()); } g_loadedTasks.fetch_add(1); { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile.clear(); } SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError()); return nullptr; } // Mark this task as completed g_loadedTasks.fetch_add(1); { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile.clear(); } if (resolvedPath != path) { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str()); } return texture; } enum class LevelBackgroundPhase { Idle, ZoomOut, ZoomIn }; struct LevelBackgroundFader { SDL_Texture* currentTex = nullptr; SDL_Texture* nextTex = nullptr; int currentLevel = -1; int queuedLevel = -1; float phaseElapsedMs = 0.0f; float phaseDurationMs = 0.0f; float fadeDurationMs = Config::Gameplay::LEVEL_FADE_DURATION; LevelBackgroundPhase phase = LevelBackgroundPhase::Idle; }; static float getPhaseDurationMs(const LevelBackgroundFader& fader, LevelBackgroundPhase phase) { const float total = std::max(1200.0f, fader.fadeDurationMs); switch (phase) { case LevelBackgroundPhase::ZoomOut: return total * 0.45f; case LevelBackgroundPhase::ZoomIn: return total * 0.45f; case LevelBackgroundPhase::Idle: default: return 0.0f; } } static void setPhase(LevelBackgroundFader& fader, LevelBackgroundPhase nextPhase) { fader.phase = nextPhase; fader.phaseDurationMs = getPhaseDurationMs(fader, nextPhase); fader.phaseElapsedMs = 0.0f; } static void destroyTexture(SDL_Texture*& tex) { if (tex) { SDL_DestroyTexture(tex); tex = nullptr; } } static bool queueLevelBackground(LevelBackgroundFader& fader, SDL_Renderer* renderer, int level) { if (!renderer) { return false; } level = std::clamp(level, 0, 32); if (fader.currentLevel == level || fader.queuedLevel == level) { return true; } char bgPath[256]; std::snprintf(bgPath, sizeof(bgPath), "assets/images/levels/level%d.jpg", level); SDL_Texture* newTexture = loadTextureFromImage(renderer, bgPath); if (!newTexture) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to queue background for level %d: %s", level, bgPath); return false; } destroyTexture(fader.nextTex); fader.nextTex = newTexture; fader.queuedLevel = level; if (!fader.currentTex) { // First background load happens instantly. fader.currentTex = fader.nextTex; fader.currentLevel = fader.queuedLevel; fader.nextTex = nullptr; fader.queuedLevel = -1; fader.phase = LevelBackgroundPhase::Idle; fader.phaseElapsedMs = 0.0f; fader.phaseDurationMs = 0.0f; } else if (fader.phase == LevelBackgroundPhase::Idle) { // Kick off fancy transition. setPhase(fader, LevelBackgroundPhase::ZoomOut); } return true; } static void updateLevelBackgroundFade(LevelBackgroundFader& fader, float frameMs) { if (fader.phase == LevelBackgroundPhase::Idle) { return; } // Guard against missing textures if (!fader.currentTex && !fader.nextTex) { fader.phase = LevelBackgroundPhase::Idle; return; } fader.phaseElapsedMs += frameMs; if (fader.phaseElapsedMs < std::max(1.0f, fader.phaseDurationMs)) { return; } switch (fader.phase) { case LevelBackgroundPhase::ZoomOut: // After zoom-out, swap textures then start zoom-in. if (fader.nextTex) { destroyTexture(fader.currentTex); fader.currentTex = fader.nextTex; fader.currentLevel = fader.queuedLevel; fader.nextTex = nullptr; fader.queuedLevel = -1; } setPhase(fader, LevelBackgroundPhase::ZoomIn); break; case LevelBackgroundPhase::ZoomIn: fader.phase = LevelBackgroundPhase::Idle; fader.phaseElapsedMs = 0.0f; fader.phaseDurationMs = 0.0f; break; case LevelBackgroundPhase::Idle: default: fader.phase = LevelBackgroundPhase::Idle; break; } } static void renderScaledBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float scale, Uint8 alpha = 255) { if (!renderer || !tex) { return; } scale = std::max(0.5f, scale); SDL_FRect dest{ (winW - winW * scale) * 0.5f, (winH - winH * scale) * 0.5f, winW * scale, winH * scale }; SDL_SetTextureAlphaMod(tex, alpha); SDL_RenderTexture(renderer, tex, nullptr, &dest); SDL_SetTextureAlphaMod(tex, 255); } static void renderDynamicBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float baseScale, float motionClockMs, float alphaMul = 1.0f) { if (!renderer || !tex) { return; } const float seconds = motionClockMs * 0.001f; const float wobble = std::max(0.4f, baseScale + std::sin(seconds * 0.07f) * 0.02f + std::sin(seconds * 0.23f) * 0.01f); const float rotation = std::sin(seconds * 0.035f) * 1.25f; const float panX = std::sin(seconds * 0.11f) * winW * 0.02f; const float panY = std::cos(seconds * 0.09f) * winH * 0.015f; SDL_FRect dest{ (winW - winW * wobble) * 0.5f + panX, (winH - winH * wobble) * 0.5f + panY, winW * wobble, winH * wobble }; SDL_FPoint center{dest.w * 0.5f, dest.h * 0.5f}; Uint8 alpha = static_cast(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f); SDL_SetTextureAlphaMod(tex, alpha); SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, ¢er, SDL_FLIP_NONE); SDL_SetTextureAlphaMod(tex, 255); } static void drawOverlay(SDL_Renderer* renderer, const SDL_FRect& rect, SDL_Color color, Uint8 alpha) { if (!renderer || alpha == 0) { return; } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, alpha); SDL_RenderFillRect(renderer, &rect); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Renderer* renderer, int winW, int winH, float motionClockMs) { if (!renderer) { return; } SDL_FRect fullRect{0.f, 0.f, static_cast(winW), static_cast(winH)}; const float duration = std::max(1.0f, fader.phaseDurationMs); const float progress = (fader.phase == LevelBackgroundPhase::Idle) ? 0.0f : std::clamp(fader.phaseElapsedMs / duration, 0.0f, 1.0f); const float seconds = motionClockMs * 0.001f; switch (fader.phase) { case LevelBackgroundPhase::ZoomOut: { const float scale = 1.0f + progress * 0.15f; if (fader.currentTex) { renderDynamicBackground(renderer, fader.currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f)); drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, Uint8(progress * 200.0f)); } break; } case LevelBackgroundPhase::ZoomIn: { const float scale = 1.10f - progress * 0.10f; const Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f); if (fader.currentTex) { renderDynamicBackground(renderer, fader.currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f); } break; } case LevelBackgroundPhase::Idle: default: if (fader.currentTex) { renderDynamicBackground(renderer, fader.currentTex, winW, winH, 1.02f, motionClockMs, 1.0f); float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f)); drawOverlay(renderer, fullRect, SDL_Color{5, 12, 28, 255}, Uint8(pulse * 90.0f)); } else if (fader.nextTex) { renderDynamicBackground(renderer, fader.nextTex, winW, winH, 1.02f, motionClockMs, 1.0f); } else { drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, 255); } break; } } static void resetLevelBackgrounds(LevelBackgroundFader& fader) { destroyTexture(fader.currentTex); destroyTexture(fader.nextTex); fader.currentLevel = -1; fader.queuedLevel = -1; fader.phaseElapsedMs = 0.0f; fader.phaseDurationMs = 0.0f; fader.phase = LevelBackgroundPhase::Idle; } // Hover state for level popup ( -1 = none, 0..19 = hovered level ) // Now managed by LevelSelectorState // ...existing code... // Legacy rendering functions removed (moved to UIRenderer / GameRenderer) // ----------------------------------------------------------------------------- // Starfield effect for background // ----------------------------------------------------------------------------- // Starfield now managed by Starfield class // State manager integration (scaffolded in StateManager.h) #include "core/state/StateManager.h" // ----------------------------------------------------------------------------- // Intro/Menu state variables // ----------------------------------------------------------------------------- #include "app/BackgroundManager.h" #include "app/Fireworks.h" static double logoAnimCounter = 0.0; static bool showSettingsPopup = false; static bool showHelpOverlay = false; static bool showExitConfirmPopup = false; static int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO static bool musicEnabled = true; static int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings static bool isNewHighScore = false; static std::string playerName = ""; static bool helpOverlayPausedGame = false; // Fireworks implementation moved to app/Fireworks.{h,cpp} int main(int, char **) { // Initialize random seed for procedural effects srand(static_cast(SDL_GetTicks())); // Load settings Settings::instance().load(); // Sync static variables with settings musicEnabled = Settings::instance().isMusicEnabled(); playerName = Settings::instance().getPlayerName(); if (playerName.empty()) playerName = "Player"; // Apply sound settings to manager SoundEffectManager::instance().setEnabled(Settings::instance().isSoundEnabled()); int sdlInitRes = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO); if (sdlInitRes < 0) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed: %s", SDL_GetError()); return 1; } int ttfInitRes = TTF_Init(); if (ttfInitRes < 0) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "TTF_Init failed"); SDL_Quit(); return 1; } SDL_WindowFlags windowFlags = SDL_WINDOW_RESIZABLE; if (Settings::instance().isFullscreen()) { windowFlags |= SDL_WINDOW_FULLSCREEN; } SDL_Window *window = SDL_CreateWindow("Tetris (SDL3)", LOGICAL_W, LOGICAL_H, windowFlags); if (!window) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateWindow failed: %s", SDL_GetError()); TTF_Quit(); SDL_Quit(); return 1; } SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr); if (!renderer) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError()); SDL_DestroyWindow(window); TTF_Quit(); SDL_Quit(); return 1; } SDL_SetRenderVSync(renderer, 1); if (const char* basePathRaw = SDL_GetBasePath()) { std::filesystem::path exeDir(basePathRaw); AssetPath::setBasePath(exeDir.string()); #if defined(__APPLE__) // On macOS bundles launched from Finder start in /, so re-root relative paths. std::error_code ec; std::filesystem::current_path(exeDir, ec); if (ec) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Failed to set working directory to %s: %s", exeDir.string().c_str(), ec.message().c_str()); } #endif } else { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "SDL_GetBasePath() failed; asset lookups rely on current directory: %s", SDL_GetError()); } // Asset loader (creates SDL_Textures on the main thread) AssetLoader assetLoader; assetLoader.init(renderer); LoadingManager loadingManager(&assetLoader); // Font and UI asset handles (actual loading deferred until Loading state) FontAtlas pixelFont; FontAtlas font; ScoreManager scores; std::atomic scoresLoadComplete{false}; // Load scores asynchronously but keep the worker alive until shutdown to avoid lifetime issues std::jthread scoreLoader([&scores, &scoresLoadComplete]() { scores.load(); scoresLoadComplete.store(true, std::memory_order_release); }); std::jthread menuTrackLoader; Starfield starfield; starfield.init(200, LOGICAL_W, LOGICAL_H); Starfield3D starfield3D; starfield3D.init(LOGICAL_W, LOGICAL_H, 200); SpaceWarp spaceWarp; spaceWarp.init(LOGICAL_W, LOGICAL_H, 420); SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward; spaceWarp.setFlightMode(warpFlightMode); bool warpAutoPilotEnabled = true; spaceWarp.setAutoPilotEnabled(true); // Initialize line clearing effects LineEffect lineEffect; lineEffect.init(renderer); // Asset handles (textures initialized by loader thread when Loading state starts) SDL_Texture* logoTex = nullptr; int logoSmallW = 0, logoSmallH = 0; SDL_Texture* logoSmallTex = nullptr; SDL_Texture* backgroundTex = nullptr; // No static background texture is used int mainScreenW = 0, mainScreenH = 0; SDL_Texture* mainScreenTex = nullptr; // Level background manager (moved to BackgroundManager) BackgroundManager levelBackgrounds; // Default start level selection: 0 (declare here so it's in scope for all handlers) int startLevelSelection = 0; SDL_Texture* blocksTex = nullptr; SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr; // Music loading tracking int totalTracks = 0; int currentTrackLoading = 0; bool musicLoaded = false; bool musicStarted = false; bool musicLoadingStarted = false; // Loader control: execute incrementally on main thread to avoid SDL threading issues std::atomic_bool g_loadingStarted{false}; std::atomic_bool g_loadingComplete{false}; std::atomic g_loadingStep{0}; // Loading is now handled by AssetLoader + LoadingManager. // Old incremental lambda removed; use LoadingManager to queue texture loads and // perform a single step per frame. Non-texture initialization (fonts, SFX) // is performed on the first loading frame below when the loader is started. Game game(startLevelSelection); // Apply global gravity speed multiplier from config game.setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); game.reset(startLevelSelection); // Sound effects system already initialized; audio loads are handled by loader thread // Define voice line banks for gameplay callbacks std::vector singleSounds = {"well_played", "smooth_clear", "great_move"}; std::vector doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; std::vector tripleSounds = {"impressive", "triple_strike"}; std::vector tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"}; bool suppressLineVoiceForLevelUp = false; auto playVoiceCue = [&](int linesCleared) { const std::vector* bank = nullptr; switch (linesCleared) { case 1: bank = &singleSounds; break; case 2: bank = &doubleSounds; break; case 3: bank = &tripleSounds; break; default: if (linesCleared >= 4) { bank = &tetrisSounds; } break; } if (bank && !bank->empty()) { SoundEffectManager::instance().playRandomSound(*bank, 1.0f); } }; // Set up sound effect callbacks game.setSoundCallback([&, playVoiceCue](int linesCleared) { if (linesCleared <= 0) { return; } // Always play the core line-clear sound for consistency SoundEffectManager::instance().playSound("clear_line", 1.0f); // Layer a voiced callout based on the number of cleared lines if (!suppressLineVoiceForLevelUp) { playVoiceCue(linesCleared); } suppressLineVoiceForLevelUp = false; }); game.setLevelUpCallback([&](int newLevel) { SoundEffectManager::instance().playSound("new_level", 1.0f); SoundEffectManager::instance().playSound("lets_go", 1.0f); // Existing voice line suppressLineVoiceForLevelUp = true; }); AppState state = AppState::Loading; double loadingProgress = 0.0; Uint64 loadStart = SDL_GetTicks(); bool running = true; bool isFullscreen = Settings::instance().isFullscreen(); bool leftHeld = false, rightHeld = false; double moveTimerMs = 0; const double DAS = 170.0, ARR = 40.0; SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; float logicalScale = 1.f; Uint64 lastMs = SDL_GetPerformanceCounter(); enum class MenuFadePhase { None, FadeOut, FadeIn }; MenuFadePhase menuFadePhase = MenuFadePhase::None; double menuFadeClockMs = 0.0; float menuFadeAlpha = 0.0f; const double MENU_PLAY_FADE_DURATION_MS = 450.0; AppState menuFadeTarget = AppState::Menu; bool menuPlayCountdownArmed = false; bool gameplayCountdownActive = false; double gameplayCountdownElapsed = 0.0; int gameplayCountdownIndex = 0; const double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; const std::array GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; double gameplayBackgroundClockMs = 0.0; // Instantiate state manager StateManager stateMgr(state); // Prepare shared context for states StateContext ctx{}; // Allow states to access the state manager for transitions ctx.stateManager = &stateMgr; ctx.game = &game; ctx.scores = nullptr; // populated once async load finishes ctx.starfield = &starfield; ctx.starfield3D = &starfield3D; ctx.font = &font; ctx.pixelFont = &pixelFont; ctx.lineEffect = &lineEffect; ctx.logoTex = logoTex; ctx.logoSmallTex = logoSmallTex; ctx.logoSmallW = logoSmallW; ctx.logoSmallH = logoSmallH; ctx.backgroundTex = nullptr; ctx.blocksTex = blocksTex; ctx.scorePanelTex = scorePanelTex; ctx.statisticsPanelTex = statisticsPanelTex; ctx.nextPanelTex = nextPanelTex; ctx.mainScreenTex = mainScreenTex; ctx.mainScreenW = mainScreenW; ctx.mainScreenH = mainScreenH; ctx.musicEnabled = &musicEnabled; ctx.startLevelSelection = &startLevelSelection; ctx.hoveredButton = &hoveredButton; ctx.showSettingsPopup = &showSettingsPopup; ctx.showHelpOverlay = &showHelpOverlay; ctx.showExitConfirmPopup = &showExitConfirmPopup; ctx.exitPopupSelectedButton = &exitPopupSelectedButton; ctx.gameplayCountdownActive = &gameplayCountdownActive; ctx.menuPlayCountdownArmed = &menuPlayCountdownArmed; ctx.playerName = &playerName; ctx.fullscreenFlag = &isFullscreen; ctx.applyFullscreen = [window, &isFullscreen](bool enable) { SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0); isFullscreen = enable; }; ctx.queryFullscreen = [window]() -> bool { return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; }; ctx.requestQuit = [&running]() { running = false; }; auto ensureScoresLoaded = [&]() { if (scoreLoader.joinable()) { scoreLoader.join(); } if (!ctx.scores) { ctx.scores = &scores; } }; auto beginStateFade = [&](AppState targetState, bool armGameplayCountdown) { if (!ctx.stateManager) { return; } if (state == targetState) { return; } if (menuFadePhase != MenuFadePhase::None) { return; } menuFadePhase = MenuFadePhase::FadeOut; menuFadeClockMs = 0.0; menuFadeAlpha = 0.0f; menuFadeTarget = targetState; menuPlayCountdownArmed = armGameplayCountdown; gameplayCountdownActive = false; gameplayCountdownIndex = 0; gameplayCountdownElapsed = 0.0; if (!armGameplayCountdown) { game.setPaused(false); } }; auto startMenuPlayTransition = [&]() { if (!ctx.stateManager) { return; } if (state != AppState::Menu) { state = AppState::Playing; ctx.stateManager->setState(state); return; } beginStateFade(AppState::Playing, true); }; ctx.startPlayTransition = startMenuPlayTransition; auto requestStateFade = [&](AppState targetState) { if (!ctx.stateManager) { return; } if (targetState == AppState::Playing) { startMenuPlayTransition(); return; } beginStateFade(targetState, false); }; ctx.requestFadeTransition = requestStateFade; // Instantiate state objects auto loadingState = std::make_unique(ctx); auto menuState = std::make_unique(ctx); auto optionsState = std::make_unique(ctx); auto levelSelectorState = std::make_unique(ctx); auto playingState = std::make_unique(ctx); // Register handlers and lifecycle hooks stateMgr.registerHandler(AppState::Loading, [&](const SDL_Event& e){ loadingState->handleEvent(e); }); stateMgr.registerOnEnter(AppState::Loading, [&](){ loadingState->onEnter(); g_loadingStarted.store(true); }); stateMgr.registerOnExit(AppState::Loading, [&](){ loadingState->onExit(); }); stateMgr.registerHandler(AppState::Menu, [&](const SDL_Event& e){ menuState->handleEvent(e); }); stateMgr.registerOnEnter(AppState::Menu, [&](){ menuState->onEnter(); }); stateMgr.registerOnExit(AppState::Menu, [&](){ menuState->onExit(); }); stateMgr.registerHandler(AppState::Options, [&](const SDL_Event& e){ optionsState->handleEvent(e); }); stateMgr.registerOnEnter(AppState::Options, [&](){ optionsState->onEnter(); }); stateMgr.registerOnExit(AppState::Options, [&](){ optionsState->onExit(); }); stateMgr.registerHandler(AppState::LevelSelector, [&](const SDL_Event& e){ levelSelectorState->handleEvent(e); }); stateMgr.registerOnEnter(AppState::LevelSelector, [&](){ levelSelectorState->onEnter(); }); stateMgr.registerOnExit(AppState::LevelSelector, [&](){ levelSelectorState->onExit(); }); // Combined Playing state handler: run playingState handler stateMgr.registerHandler(AppState::Playing, [&](const SDL_Event& e){ // First give the PlayingState a chance to handle the event playingState->handleEvent(e); }); stateMgr.registerOnEnter(AppState::Playing, [&](){ playingState->onEnter(); }); stateMgr.registerOnExit(AppState::Playing, [&](){ playingState->onExit(); }); // Manually trigger the initial Loading state's onEnter loadingState->onEnter(); g_loadingStarted.store(true); // Playing, LevelSelect and GameOver currently use inline logic in main; we'll migrate later while (running) { if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) { ensureScoresLoaded(); } int winW = 0, winH = 0; SDL_GetWindowSize(window, &winW, &winH); // Use the full window for the viewport, scale to fit content logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H); if (logicalScale <= 0) logicalScale = 1.f; // Fill the entire window with our viewport logicalVP.w = winW; logicalVP.h = winH; logicalVP.x = 0; logicalVP.y = 0; // --- Events --- SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) running = false; else { // Route event to state manager handlers for per-state logic const bool isUserInputEvent = e.type == SDL_EVENT_KEY_DOWN || e.type == SDL_EVENT_KEY_UP || e.type == SDL_EVENT_TEXT_INPUT || e.type == SDL_EVENT_MOUSE_BUTTON_DOWN || e.type == SDL_EVENT_MOUSE_BUTTON_UP || e.type == SDL_EVENT_MOUSE_MOTION; if (!(showHelpOverlay && isUserInputEvent)) { stateMgr.handleEvent(e); // Keep the local `state` variable in sync with StateManager in case // a state handler requested a transition (handlers may call // stateMgr.setState()). Many branches below rely on the local // `state` variable, so update it immediately after handling. state = stateMgr.getState(); } // Global key toggles (applies regardless of state) if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { if (e.key.scancode == SDL_SCANCODE_M) { Audio::instance().toggleMute(); musicEnabled = !musicEnabled; Settings::instance().setMusicEnabled(musicEnabled); } if (e.key.scancode == SDL_SCANCODE_N) { Audio::instance().skipToNextTrack(); if (!musicStarted && Audio::instance().getLoadedTrackCount() > 0) { musicStarted = true; musicEnabled = true; Settings::instance().setMusicEnabled(true); } } if (e.key.scancode == SDL_SCANCODE_S) { // Toggle sound effects SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled()); Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); } // Disable H-help shortcut on the main menu; keep it elsewhere if (e.key.scancode == SDL_SCANCODE_H && state != AppState::Loading && state != AppState::Menu) { showHelpOverlay = !showHelpOverlay; if (state == AppState::Playing) { if (showHelpOverlay) { if (!game.isPaused()) { game.setPaused(true); helpOverlayPausedGame = true; } else { helpOverlayPausedGame = false; } } else if (helpOverlayPausedGame) { game.setPaused(false); helpOverlayPausedGame = false; } } else if (!showHelpOverlay) { helpOverlayPausedGame = false; } } // If help overlay is visible and the user presses ESC, close help and return to Menu if (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) { showHelpOverlay = false; helpOverlayPausedGame = false; // Unpause game if we paused it for the overlay if (state == AppState::Playing) { if (game.isPaused() && !helpOverlayPausedGame) { // If paused for other reasons, avoid overriding; otherwise ensure unpaused // (The flag helps detect pause because of help overlay.) } } if (state != AppState::Menu && ctx.requestFadeTransition) { // Request a transition back to the Menu state ctx.requestFadeTransition(AppState::Menu); } else if (state != AppState::Menu && ctx.stateManager) { state = AppState::Menu; ctx.stateManager->setState(state); } } if (e.key.key == SDLK_F11 || (e.key.key == SDLK_RETURN && (e.key.mod & SDL_KMOD_ALT))) { isFullscreen = !isFullscreen; SDL_SetWindowFullscreen(window, isFullscreen ? SDL_WINDOW_FULLSCREEN : 0); Settings::instance().setFullscreen(isFullscreen); } if (e.key.scancode == SDL_SCANCODE_F5) { warpAutoPilotEnabled = false; warpFlightMode = SpaceWarpFlightMode::Forward; spaceWarp.setFlightMode(warpFlightMode); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: forward"); } if (e.key.scancode == SDL_SCANCODE_F6) { warpAutoPilotEnabled = false; warpFlightMode = SpaceWarpFlightMode::BankLeft; spaceWarp.setFlightMode(warpFlightMode); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank left"); } if (e.key.scancode == SDL_SCANCODE_F7) { warpAutoPilotEnabled = false; warpFlightMode = SpaceWarpFlightMode::BankRight; spaceWarp.setFlightMode(warpFlightMode); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank right"); } if (e.key.scancode == SDL_SCANCODE_F8) { warpAutoPilotEnabled = false; warpFlightMode = SpaceWarpFlightMode::Reverse; spaceWarp.setFlightMode(warpFlightMode); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: reverse"); } if (e.key.scancode == SDL_SCANCODE_F9) { warpAutoPilotEnabled = true; spaceWarp.setAutoPilotEnabled(true); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp autopilot engaged"); } } // Text input for high score if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) { if (playerName.length() < 12) { playerName += e.text.text; } } if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { if (isNewHighScore) { if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) { playerName.pop_back(); } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { if (playerName.empty()) playerName = "PLAYER"; ensureScoresLoaded(); scores.submit(game.score(), game.lines(), game.level(), game.elapsed(), playerName); Settings::instance().setPlayerName(playerName); isNewHighScore = false; SDL_StopTextInput(window); } } else { if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) { // Restart game.reset(startLevelSelection); state = AppState::Playing; stateMgr.setState(state); } else if (e.key.scancode == SDL_SCANCODE_ESCAPE) { // Menu state = AppState::Menu; stateMgr.setState(state); } } } // Mouse handling remains in main loop for UI interactions if (!showHelpOverlay && e.type == SDL_EVENT_MOUSE_BUTTON_DOWN) { float mx = (float)e.button.x, my = (float)e.button.y; if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h) { float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale; if (state == AppState::Menu) { // Compute content offsets (match MenuState centering) float contentW = LOGICAL_W * logicalScale; float contentH = LOGICAL_H * logicalScale; float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; if (showSettingsPopup) { // Click anywhere closes settings popup showSettingsPopup = false; } else { ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; auto buttonRects = ui::computeMenuButtonRects(params); auto pointInRect = [&](const SDL_FRect& r) { return lx >= r.x && lx <= r.x + r.w && ly >= r.y && ly <= r.y + r.h; }; if (pointInRect(buttonRects[0])) { startMenuPlayTransition(); } else if (pointInRect(buttonRects[1])) { requestStateFade(AppState::LevelSelector); } else if (pointInRect(buttonRects[2])) { requestStateFade(AppState::Options); } else if (pointInRect(buttonRects[3])) { // HELP - show inline help HUD in the MenuState if (menuState) menuState->showHelpPanel(true); } else if (pointInRect(buttonRects[4])) { showExitConfirmPopup = true; exitPopupSelectedButton = 1; } // Settings button (gear icon area - top right) SDL_FRect settingsBtn{SETTINGS_BTN_X, SETTINGS_BTN_Y, SETTINGS_BTN_W, SETTINGS_BTN_H}; if (lx >= settingsBtn.x && lx <= settingsBtn.x + settingsBtn.w && ly >= settingsBtn.y && ly <= settingsBtn.y + settingsBtn.h) { showSettingsPopup = true; } } } else if (state == AppState::LevelSelect) startLevelSelection = (startLevelSelection + 1) % 20; else if (state == AppState::GameOver) { state = AppState::Menu; stateMgr.setState(state); } else if (state == AppState::Playing && showExitConfirmPopup) { // Convert mouse to logical coordinates and to content-local coords float lx = (mx - logicalVP.x) / logicalScale; float ly = (my - logicalVP.y) / logicalScale; // Compute content offsets (same as in render path) float contentW = LOGICAL_W * logicalScale; float contentH = LOGICAL_H * logicalScale; float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; // Map to content-local logical coords (what drawing code uses) float localX = lx - contentOffsetX; float localY = ly - contentOffsetY; // Popup rect in logical coordinates (content-local) float popupW = 400, popupH = 200; float popupX = (LOGICAL_W - popupW) / 2.0f; float popupY = (LOGICAL_H - popupH) / 2.0f; // Simple Yes/No buttons float btnW = 120.0f, btnH = 40.0f; float yesX = popupX + popupW * 0.25f - btnW / 2.0f; float noX = popupX + popupW * 0.75f - btnW / 2.0f; float btnY = popupY + popupH - btnH - 20.0f; if (localX >= popupX && localX <= popupX + popupW && localY >= popupY && localY <= popupY + popupH) { // Click inside popup - check buttons if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) { // Yes -> go back to menu showExitConfirmPopup = false; game.reset(startLevelSelection); state = AppState::Menu; stateMgr.setState(state); } else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) { // No -> close popup and resume showExitConfirmPopup = false; game.setPaused(false); } } else { // Click outside popup: cancel showExitConfirmPopup = false; game.setPaused(false); } } else if (state == AppState::Menu && showExitConfirmPopup) { float contentW = LOGICAL_W * logicalScale; float contentH = LOGICAL_H * logicalScale; float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; float popupW = 420.0f; float popupH = 230.0f; float popupX = (LOGICAL_W - popupW) * 0.5f + contentOffsetX; float popupY = (LOGICAL_H - popupH) * 0.5f + contentOffsetY; float btnW = 140.0f; float btnH = 50.0f; float yesX = popupX + popupW * 0.3f - btnW / 2.0f; float noX = popupX + popupW * 0.7f - btnW / 2.0f; float btnY = popupY + popupH - btnH - 30.0f; bool insidePopup = lx >= popupX && lx <= popupX + popupW && ly >= popupY && ly <= popupY + popupH; if (insidePopup) { if (lx >= yesX && lx <= yesX + btnW && ly >= btnY && ly <= btnY + btnH) { showExitConfirmPopup = false; running = false; } else if (lx >= noX && lx <= noX + btnW && ly >= btnY && ly <= btnY + btnH) { showExitConfirmPopup = false; } } else { showExitConfirmPopup = false; } } } } else if (!showHelpOverlay && e.type == SDL_EVENT_MOUSE_MOTION) { float mx = (float)e.motion.x, my = (float)e.motion.y; if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h) { float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale; if (state == AppState::Menu && !showSettingsPopup) { // Compute content offsets and responsive buttons (match MenuState) float contentW = LOGICAL_W * logicalScale; float contentH = LOGICAL_H * logicalScale; float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; hoveredButton = ui::hitTestMenuButtons(params, lx, ly); } } } } } // --- Timing --- Uint64 now = SDL_GetPerformanceCounter(); double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); lastMs = now; // Cap frame time to avoid spiral of death (max 100ms) if (frameMs > 100.0) frameMs = 100.0; gameplayBackgroundClockMs += frameMs; const bool *ks = SDL_GetKeyboardState(nullptr); bool left = state == AppState::Playing && ks[SDL_SCANCODE_LEFT]; bool right = state == AppState::Playing && ks[SDL_SCANCODE_RIGHT]; bool down = state == AppState::Playing && ks[SDL_SCANCODE_DOWN]; // Inform game about soft-drop state for scoring parity (1 point per cell when holding Down) if (state == AppState::Playing) game.setSoftDropping(down && !game.isPaused()); else game.setSoftDropping(false); // Handle DAS/ARR int moveDir = 0; if (left && !right) moveDir = -1; else if (right && !left) moveDir = +1; if (moveDir != 0 && !game.isPaused()) { if ((moveDir == -1 && leftHeld == false) || (moveDir == +1 && rightHeld == false)) { game.move(moveDir); moveTimerMs = DAS; } else { moveTimerMs -= frameMs; if (moveTimerMs <= 0) { game.move(moveDir); moveTimerMs += ARR; } } } else moveTimerMs = 0; leftHeld = left; rightHeld = right; if (down && !game.isPaused()) game.softDropBoost(frameMs); // Track music loading on every frame so it finishes even after the loading screen ends if (musicLoadingStarted && !musicLoaded) { currentTrackLoading = Audio::instance().getLoadedTrackCount(); if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) { Audio::instance().shuffle(); // Defer starting playback until the app has entered the Menu/Playing state. // Actual playback is started below when `musicLoaded` is observed and // the state is Menu or Playing (so the user doesn't hear music while // still on the Loading screen). musicLoaded = true; } } if (state == AppState::Playing) { if (!game.isPaused()) { game.tickGravity(frameMs); game.updateElapsedTime(); // Update line effect and clear lines when animation completes if (lineEffect.isActive()) { if (lineEffect.update(frameMs / 1000.0f)) { // Effect is complete, now actually clear the lines game.clearCompletedLines(); } } } if (game.isGameOver()) { // Always allow name entry if score > 0 if (game.score() > 0) { isNewHighScore = true; // Reuse flag to trigger input mode playerName = ""; SDL_StartTextInput(window); } else { isNewHighScore = false; ensureScoresLoaded(); scores.submit(game.score(), game.lines(), game.level(), game.elapsed()); } state = AppState::GameOver; stateMgr.setState(state); } } else if (state == AppState::Loading) { static int queuedTextureCount = 0; // Execute one loading step per frame on main thread via LoadingManager if (g_loadingStarted.load() && !g_loadingComplete.load()) { static bool queuedTextures = false; static std::vector queuedPaths; if (!queuedTextures) { queuedTextures = true; // Initialize counters and clear previous errors constexpr int baseTasks = 25; // keep same budget as before g_totalLoadingTasks.store(baseTasks); g_loadedTasks.store(0); { std::lock_guard lk(g_assetLoadErrorsMutex); g_assetLoadErrors.clear(); } { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile.clear(); } // Initialize background music loading Audio::instance().init(); totalTracks = 0; for (int i = 1; i <= 100; ++i) { char base[128]; std::snprintf(base, sizeof(base), "assets/music/music%03d", i); std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" }); if (path.empty()) break; Audio::instance().addTrackAsync(path); totalTracks++; } g_totalLoadingTasks.store(baseTasks + totalTracks); if (totalTracks > 0) { Audio::instance().startBackgroundLoading(); musicLoadingStarted = true; } else { musicLoaded = true; } // Initialize fonts (synchronous, cheap) pixelFont.init(AssetPath::resolveWithBase("assets/fonts/Orbitron.ttf"), 22); g_loadedTasks.fetch_add(1); font.init(AssetPath::resolveWithBase("assets/fonts/Exo2.ttf"), 20); g_loadedTasks.fetch_add(1); // Queue UI textures for incremental loading queuedPaths = { "assets/images/spacetris.png", "assets/images/spacetris.png", // small logo uses same source "assets/images/main_screen.png", "assets/images/blocks90px_001.bmp", "assets/images/panel_score.png", "assets/images/statistics_panel.png", "assets/images/next_panel.png" }; for (auto &p : queuedPaths) { loadingManager.queueTexture(p); } queuedTextureCount = static_cast(queuedPaths.size()); // Initialize sound effects manager (counts as a loaded task) SoundEffectManager::instance().init(); g_loadedTasks.fetch_add(1); // Load small set of voice/audio SFX synchronously for now (keeps behavior) const std::vector audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level"}; for (const auto &id : audioIds) { std::string basePath = "assets/music/" + (id == "hard_drop" ? "hard_drop_001" : id); { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile = basePath; } std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" }); if (!resolved.empty()) { SoundEffectManager::instance().loadSound(id, resolved); } g_loadedTasks.fetch_add(1); { std::lock_guard lk(g_currentLoadingMutex); g_currentLoadingFile.clear(); } } } // Perform a single texture loading step via LoadingManager bool texturesDone = loadingManager.update(); if (texturesDone) { // Bind loaded textures into the runtime context logoTex = assetLoader.getTexture("assets/images/spacetris.png"); logoSmallTex = assetLoader.getTexture("assets/images/spacetris.png"); mainScreenTex = assetLoader.getTexture("assets/images/main_screen.png"); blocksTex = assetLoader.getTexture("assets/images/blocks90px_001.bmp"); scorePanelTex = assetLoader.getTexture("assets/images/panel_score.png"); statisticsPanelTex = assetLoader.getTexture("assets/images/statistics_panel.png"); nextPanelTex = assetLoader.getTexture("assets/images/next_panel.png"); auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) { if (!tex) return; if (outW > 0 && outH > 0) return; float w = 0.0f, h = 0.0f; if (SDL_GetTextureSize(tex, &w, &h)) { outW = static_cast(std::lround(w)); outH = static_cast(std::lround(h)); } }; // If a texture was created by AssetLoader (not legacy IMG_Load), // its stored width/height may still be 0. Query the real size. ensureTextureSize(logoSmallTex, logoSmallW, logoSmallH); ensureTextureSize(mainScreenTex, mainScreenW, mainScreenH); // Fallback: if any critical UI texture failed to load via AssetLoader, // load synchronously using the legacy helper so the Menu can render. auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) { if (!outTex) { outTex = loadTextureFromImage(renderer, p, outW, outH); } }; legacyLoad("assets/images/spacetris.png", logoTex); legacyLoad("assets/images/spacetris.png", logoSmallTex, &logoSmallW, &logoSmallH); legacyLoad("assets/images/main_screen.png", mainScreenTex, &mainScreenW, &mainScreenH); legacyLoad("assets/images/blocks90px_001.bmp", blocksTex); legacyLoad("assets/images/panel_score.png", scorePanelTex); legacyLoad("assets/images/statistics_panel.png", statisticsPanelTex); legacyLoad("assets/images/next_panel.png", nextPanelTex); // If blocks texture failed, create fallback and count it as loaded if (!blocksTex) { blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90); SDL_SetRenderTarget(renderer, blocksTex); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); SDL_RenderClear(renderer); for (int i = 0; i < PIECE_COUNT; ++i) { SDL_Color c = COLORS[i + 1]; SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); SDL_FRect rect{(float)(i * 90), 0, 90, 90}; SDL_RenderFillRect(renderer, &rect); } SDL_SetRenderTarget(renderer, nullptr); // Do not update global task counter here; textures are accounted // for via the LoadingManager/AssetLoader progress below. } // Mark loading complete when music also loaded if (musicLoaded) { g_loadingComplete.store(true); } } } // Prefer task-based progress if we have tasks registered const int totalTasks = g_totalLoadingTasks.load(std::memory_order_acquire); const int musicDone = std::min(totalTracks, currentTrackLoading); int doneTasks = g_loadedTasks.load(std::memory_order_acquire) + musicDone; // Include texture progress reported by the LoadingManager/AssetLoader if (queuedTextureCount > 0) { float texProg = loadingManager.getProgress(); int texDone = static_cast(std::floor(texProg * queuedTextureCount + 0.5f)); if (texDone > queuedTextureCount) texDone = queuedTextureCount; doneTasks += texDone; } if (doneTasks > totalTasks) doneTasks = totalTasks; if (totalTasks > 0) { loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks)); if (loadingProgress >= 1.0 && musicLoaded) { state = AppState::Menu; stateMgr.setState(state); } } else { // Fallback: time + audio heuristics (legacy behavior) double assetProgress = 0.2; double musicProgress = 0.0; if (totalTracks > 0) { musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7); } else { if (Audio::instance().isLoadingComplete()) { musicProgress = 0.7; } else if (Audio::instance().getLoadedTrackCount() > 0) { musicProgress = 0.35; } else { Uint32 elapsedMs = SDL_GetTicks() - static_cast(loadStart); if (elapsedMs > 1500) { musicProgress = 0.7; musicLoaded = true; } else { musicProgress = 0.0; } } } double timeProgress = std::min(0.1, (now - loadStart) / 500.0); loadingProgress = std::min(1.0, assetProgress + musicProgress + timeProgress); if (loadingProgress > 0.99) loadingProgress = 1.0; if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0; if (loadingProgress >= 1.0 && musicLoaded) { state = AppState::Menu; stateMgr.setState(state); } } } if (state == AppState::Menu || state == AppState::Playing) { if (!musicStarted && musicLoaded) { // Load menu track once on first menu entry (in background to avoid blocking) static bool menuTrackLoaded = false; if (!menuTrackLoaded) { if (menuTrackLoader.joinable()) { menuTrackLoader.join(); } menuTrackLoader = std::jthread([]() { std::string menuTrack = AssetPath::resolveWithExtensions("assets/music/Every Block You Take", { ".mp3" }); if (!menuTrack.empty()) { Audio::instance().setMenuTrack(menuTrack); } else { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Menu track not found (Every Block You Take)"); } }); menuTrackLoaded = true; } // Start appropriate music based on state if (state == AppState::Menu) { Audio::instance().playMenuMusic(); } else { Audio::instance().playGameMusic(); } musicStarted = true; } } // Handle music transitions between states static AppState previousState = AppState::Loading; if (state != previousState && musicStarted) { if (state == AppState::Menu && previousState == AppState::Playing) { // Switched from game to menu Audio::instance().playMenuMusic(); } else if (state == AppState::Playing && previousState == AppState::Menu) { // Switched from menu to game Audio::instance().playGameMusic(); } } previousState = state; // Update background effects if (state == AppState::Loading) { starfield3D.update(float(frameMs / 1000.0f)); starfield3D.resize(winW, winH); } else { starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h); } if (state == AppState::Menu) { spaceWarp.resize(winW, winH); spaceWarp.update(float(frameMs / 1000.0f)); } // Advance level background fade if a next texture is queued levelBackgrounds.update(float(frameMs)); // Update intro animations if (state == AppState::Menu) { logoAnimCounter += frameMs * 0.0008; // Animation speed } // --- Per-state update hooks (allow states to manage logic incrementally) switch (stateMgr.getState()) { case AppState::Loading: loadingState->update(frameMs); break; case AppState::Menu: menuState->update(frameMs); break; case AppState::Options: optionsState->update(frameMs); break; case AppState::LevelSelector: levelSelectorState->update(frameMs); break; case AppState::Playing: playingState->update(frameMs); break; default: break; } // Keep context asset pointers in sync with assets loaded by the loader thread ctx.logoTex = logoTex; ctx.logoSmallTex = logoSmallTex; ctx.logoSmallW = logoSmallW; ctx.logoSmallH = logoSmallH; ctx.backgroundTex = backgroundTex; ctx.blocksTex = blocksTex; ctx.scorePanelTex = scorePanelTex; ctx.statisticsPanelTex = statisticsPanelTex; ctx.nextPanelTex = nextPanelTex; ctx.mainScreenTex = mainScreenTex; ctx.mainScreenW = mainScreenW; ctx.mainScreenH = mainScreenH; if (menuFadePhase == MenuFadePhase::FadeOut) { menuFadeClockMs += frameMs; menuFadeAlpha = std::min(1.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS)); if (menuFadeClockMs >= MENU_PLAY_FADE_DURATION_MS) { if (state != menuFadeTarget) { state = menuFadeTarget; stateMgr.setState(state); } if (menuFadeTarget == AppState::Playing) { menuPlayCountdownArmed = true; gameplayCountdownActive = false; gameplayCountdownIndex = 0; gameplayCountdownElapsed = 0.0; game.setPaused(true); } else { menuPlayCountdownArmed = false; gameplayCountdownActive = false; gameplayCountdownIndex = 0; gameplayCountdownElapsed = 0.0; game.setPaused(false); } menuFadePhase = MenuFadePhase::FadeIn; menuFadeClockMs = MENU_PLAY_FADE_DURATION_MS; menuFadeAlpha = 1.0f; } } else if (menuFadePhase == MenuFadePhase::FadeIn) { menuFadeClockMs -= frameMs; menuFadeAlpha = std::max(0.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS)); if (menuFadeClockMs <= 0.0) { menuFadePhase = MenuFadePhase::None; menuFadeClockMs = 0.0; menuFadeAlpha = 0.0f; } } if (menuFadePhase == MenuFadePhase::None && menuPlayCountdownArmed && !gameplayCountdownActive && state == AppState::Playing) { gameplayCountdownActive = true; menuPlayCountdownArmed = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; game.setPaused(true); } if (gameplayCountdownActive && state == AppState::Playing) { gameplayCountdownElapsed += frameMs; if (gameplayCountdownElapsed >= GAMEPLAY_COUNTDOWN_STEP_MS) { gameplayCountdownElapsed -= GAMEPLAY_COUNTDOWN_STEP_MS; ++gameplayCountdownIndex; if (gameplayCountdownIndex >= static_cast(GAMEPLAY_COUNTDOWN_LABELS.size())) { gameplayCountdownActive = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; game.setPaused(false); } } } if (state != AppState::Playing && gameplayCountdownActive) { gameplayCountdownActive = false; menuPlayCountdownArmed = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; game.setPaused(false); } // --- Render --- SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_RenderClear(renderer); // Draw level-based background for gameplay, starfield for other states if (state == AppState::Playing) { int bgLevel = std::clamp(game.level(), 0, 32); levelBackgrounds.queueLevelBackground(renderer, bgLevel); levelBackgrounds.render(renderer, winW, winH, static_cast(gameplayBackgroundClockMs)); } else if (state == AppState::Loading) { // Use 3D starfield for loading screen (full screen) starfield3D.draw(renderer); } else if (state == AppState::Menu) { // Space flyover backdrop for the main screen spaceWarp.draw(renderer, 1.0f); // `mainScreenTex` is rendered as a top layer just before presenting // so we don't draw it here. Keep the space warp background only. } else if (state == AppState::LevelSelector || state == AppState::Options) { // No static background texture to draw (background image removed). } else { // Use regular starfield for other states (not gameplay) starfield.draw(renderer); } SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); switch (state) { case AppState::Loading: { // Calculate actual content area (centered within the window) float contentScale = logicalScale; float contentW = LOGICAL_W * contentScale; float contentH = LOGICAL_H * contentScale; float contentOffsetX = (winW - contentW) * 0.5f / contentScale; float contentOffsetY = (winH - contentH) * 0.5f / contentScale; auto drawRect = [&](float x, float y, float w, float h, SDL_Color c) { SDL_SetRenderDrawColor(renderer,c.r,c.g,c.b,c.a); SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h}; SDL_RenderFillRect(renderer,&fr); }; // Calculate dimensions for perfect centering (like JavaScript version) const bool isLimitedHeight = LOGICAL_H < 450; const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0; const float loadingTextHeight = 20; // Height of "LOADING" text (match JS) const float barHeight = 20; // Loading bar height (match JS) const float barPaddingVertical = isLimitedHeight ? 15 : 35; const float percentTextHeight = 24; // Height of percentage text const float spacingBetweenElements = isLimitedHeight ? 5 : 15; // Total content height const float totalContentHeight = logoHeight + (logoHeight > 0 ? spacingBetweenElements : 0) + loadingTextHeight + barPaddingVertical + barHeight + spacingBetweenElements + percentTextHeight; // Start Y position for perfect vertical centering float currentY = (LOGICAL_H - totalContentHeight) / 2.0f; // Draw logo (centered, static like JavaScript version) if (logoTex) { // Use the same original large logo dimensions as JS (we used a half-size BMP previously) const int lw = 872, lh = 273; // Cap logo width similar to JS UI.MAX_LOGO_WIDTH (600) and available screen space const float maxLogoWidth = std::min(LOGICAL_W * 0.9f, 600.0f); const float availableHeight = isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f; const float availableWidth = maxLogoWidth; const float scaleFactorWidth = availableWidth / static_cast(lw); const float scaleFactorHeight = availableHeight / static_cast(lh); const float scaleFactor = std::min(scaleFactorWidth, scaleFactorHeight); const float displayWidth = lw * scaleFactor; const float displayHeight = lh * scaleFactor; const float logoX = (LOGICAL_W - displayWidth) / 2.0f; SDL_FRect dst{logoX + contentOffsetX, currentY + contentOffsetY, displayWidth, displayHeight}; SDL_RenderTexture(renderer, logoTex, nullptr, &dst); currentY += displayHeight + spacingBetweenElements; } // Draw "LOADING" text (centered, using pixel font) const char* loadingText = "LOADING"; float textWidth = strlen(loadingText) * 12.0f; // Approximate width for pixel font float textX = (LOGICAL_W - textWidth) / 2.0f; pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255}); currentY += loadingTextHeight + barPaddingVertical; // Draw loading bar (like JavaScript version) const int barW = 400, barH = 20; const int bx = (LOGICAL_W - barW) / 2; // Bar border (dark gray) - using drawRect which adds content offset drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255}); // Bar background (darker gray) drawRect(bx, currentY, barW, barH, {34, 34, 34, 255}); // Progress bar (gold color) drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255}); currentY += barH + spacingBetweenElements; // Draw percentage text (centered, using pixel font) int percentage = int(loadingProgress * 100); char percentText[16]; std::snprintf(percentText, sizeof(percentText), "%d%%", percentage); float percentWidth = strlen(percentText) * 12.0f; // Approximate width for pixel font float percentX = (LOGICAL_W - percentWidth) / 2.0f; pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255}); // If any asset/audio errors occurred during startup, display recent ones in red { std::lock_guard lk(g_assetLoadErrorsMutex); const int maxShow = 5; int count = static_cast(g_assetLoadErrors.size()); if (count > 0) { int start = std::max(0, count - maxShow); float errY = currentY + spacingBetweenElements + 8.0f; // Also make a visible window title change so users notice missing assets std::string latest = g_assetLoadErrors.back(); std::string shortTitle = "Tetris - Missing assets"; if (!latest.empty()) { std::string trimmed = latest; if (trimmed.size() > 48) trimmed = trimmed.substr(0, 45) + "..."; shortTitle += ": "; shortTitle += trimmed; } SDL_SetWindowTitle(window, shortTitle.c_str()); // Also append a trace log entry for visibility outside the SDL window FILE* tf = fopen("tetris_trace.log", "a"); if (tf) { fprintf(tf, "Loading error: %s\n", g_assetLoadErrors.back().c_str()); fclose(tf); } for (int i = start; i < count; ++i) { const std::string& msg = g_assetLoadErrors[i]; // Truncate long messages to fit reasonably std::string display = msg; if (display.size() > 80) display = display.substr(0, 77) + "..."; pixelFont.draw(renderer, 80 + contentOffsetX, errY + contentOffsetY, display.c_str(), 0.85f, {255, 100, 100, 255}); errY += 20.0f; } } } // Debug overlay: show current loading file and counters when enabled in settings if (Settings::instance().isDebugEnabled()) { std::string cur; { std::lock_guard lk(g_currentLoadingMutex); cur = g_currentLoadingFile; } char buf[128]; int loaded = g_loadedTasks.load(); int total = g_totalLoadingTasks.load(); std::snprintf(buf, sizeof(buf), "Loaded: %d / %d", loaded, total); float debugX = 20.0f + contentOffsetX; float debugY = LOGICAL_H - 48.0f + contentOffsetY; pixelFont.draw(renderer, debugX, debugY, buf, 0.9f, SDL_Color{200,200,200,255}); if (!cur.empty()) { std::string display = "Loading: "; display += cur; if (display.size() > 80) display = display.substr(0,77) + "..."; pixelFont.draw(renderer, debugX, debugY + 18.0f, display.c_str(), 0.85f, SDL_Color{200,180,120,255}); } } } break; case AppState::Menu: // Ensure overlay is loaded (drawn after highscores so it sits above that layer) if (!mainScreenTex) { mainScreenTex = loadTextureFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH); } // Render menu content that should appear *behind* the overlay (highscores/logo). // Bottom buttons are drawn separately on top. if (menuState) { menuState->drawMainButtonNormally = false; menuState->render(renderer, logicalScale, logicalVP); } // Draw main screen overlay above highscores if (mainScreenTex) { SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderScale(renderer, 1.f, 1.f); float texW = mainScreenW > 0 ? static_cast(mainScreenW) : 0.0f; float texH = mainScreenH > 0 ? static_cast(mainScreenH) : 0.0f; if (texW <= 0.0f || texH <= 0.0f) { float iwf = 0.0f, ihf = 0.0f; if (!SDL_GetTextureSize(mainScreenTex, &iwf, &ihf)) { iwf = ihf = 0.0f; } texW = iwf; texH = ihf; } if (texW > 0.0f && texH > 0.0f) { const float drawH = static_cast(winH); const float scale = drawH / texH; const float drawW = texW * scale; SDL_FRect dst{ (winW - drawW) * 0.5f, 0.0f, drawW, drawH }; SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND); SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst); } SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); } // Draw bottom menu buttons above the overlay if (menuState) { menuState->renderMainButtonTop(renderer, logicalScale, logicalVP); } break; case AppState::Options: optionsState->render(renderer, logicalScale, logicalVP); break; case AppState::LevelSelector: // Delegate level selector rendering to LevelSelectorState levelSelectorState->render(renderer, logicalScale, logicalVP); break; case AppState::LevelSelect: { const std::string title = "SELECT LEVEL"; int tW = 0, tH = 0; font.measure(title, 2.5f, tW, tH); float titleX = (LOGICAL_W - (float)tW) / 2.0f; font.draw(renderer, titleX, 80, title, 2.5f, SDL_Color{255, 220, 0, 255}); char buf[64]; std::snprintf(buf, sizeof(buf), "LEVEL: %d", startLevelSelection); font.draw(renderer, LOGICAL_W * 0.5f - 80, 180, buf, 2.0f, SDL_Color{200, 240, 255, 255}); font.draw(renderer, LOGICAL_W * 0.5f - 180, 260, "ARROWS CHANGE ENTER=OK ESC=BACK", 1.2f, SDL_Color{200, 200, 220, 255}); } break; case AppState::Playing: playingState->render(renderer, logicalScale, logicalVP); break; case AppState::GameOver: // Draw the game state in the background GameRenderer::renderPlayingState( renderer, &game, &pixelFont, &lineEffect, blocksTex, ctx.statisticsPanelTex, scorePanelTex, nextPanelTex, (float)LOGICAL_W, (float)LOGICAL_H, logicalScale, (float)winW, (float)winH ); // Draw Game Over Overlay { // 1. Dim the background SDL_SetRenderViewport(renderer, nullptr); // Use window coordinates for full screen dim SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); // Dark semi-transparent SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH}; SDL_RenderFillRect(renderer, &fullWin); // Restore logical viewport SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); // 2. Calculate content offsets (same as in GameRenderer) float contentScale = logicalScale; float contentW = LOGICAL_W * contentScale; float contentH = LOGICAL_H * contentScale; float contentOffsetX = (winW - contentW) * 0.5f / contentScale; float contentOffsetY = (winH - contentH) * 0.5f / contentScale; // 3. Draw Game Over Box float boxW = 500.0f; float boxH = 350.0f; float boxX = (LOGICAL_W - boxW) * 0.5f; float boxY = (LOGICAL_H - boxH) * 0.5f; // Draw box background SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH}; SDL_RenderFillRect(renderer, &boxRect); // Draw box border SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255); SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6}; SDL_RenderFillRect(renderer, &borderRect); // Use FillRect for border background effect SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); SDL_RenderFillRect(renderer, &boxRect); // Redraw background on top of border rect // 4. Draw Text // 4. Draw Text // Title ensureScoresLoaded(); bool realHighScore = scores.isHighScore(game.score()); const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER"; int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH); pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255}); // Score char scoreStr[64]; snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game.score()); int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH); pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255}); if (isNewHighScore) { // Name Entry const char* enterName = "ENTER NAME:"; int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH); pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255}); // Input box float inputW = 300.0f; float inputH = 40.0f; float inputX = boxX + (boxW - inputW) * 0.5f; float inputY = boxY + 200.0f; SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH}; SDL_RenderFillRect(renderer, &inputRect); SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255); SDL_RenderRect(renderer, &inputRect); // Player Name (blink cursor without shifting text) const float nameScale = 1.2f; const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0; int metricsW = 0, metricsH = 0; pixelFont.measure("A", nameScale, metricsW, metricsH); if (metricsH == 0) metricsH = 24; // fallback height int nameW = 0, nameH = 0; if (!playerName.empty()) { pixelFont.measure(playerName, nameScale, nameW, nameH); } else { nameH = metricsH; } float textX = inputX + (inputW - static_cast(nameW)) * 0.5f + contentOffsetX; float textY = inputY + (inputH - static_cast(metricsH)) * 0.5f + contentOffsetY; if (!playerName.empty()) { pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255}); } if (showCursor) { int cursorW = 0, cursorH = 0; pixelFont.measure("_", nameScale, cursorW, cursorH); float cursorX = playerName.empty() ? inputX + (inputW - static_cast(cursorW)) * 0.5f + contentOffsetX : textX + static_cast(nameW); float cursorY = inputY + (inputH - static_cast(cursorH)) * 0.5f + contentOffsetY; pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255}); } // Hint const char* hint = "PRESS ENTER TO SUBMIT"; int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH); pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255}); } else { // Lines char linesStr[64]; snprintf(linesStr, sizeof(linesStr), "LINES: %d", game.lines()); int lW=0, lH=0; pixelFont.measure(linesStr, 1.2f, lW, lH); pixelFont.draw(renderer, boxX + (boxW - lW) * 0.5f + contentOffsetX, boxY + 140 + contentOffsetY, linesStr, 1.2f, {255, 255, 255, 255}); // Level char levelStr[64]; snprintf(levelStr, sizeof(levelStr), "LEVEL: %d", game.level()); int lvW=0, lvH=0; pixelFont.measure(levelStr, 1.2f, lvW, lvH); pixelFont.draw(renderer, boxX + (boxW - lvW) * 0.5f + contentOffsetX, boxY + 180 + contentOffsetY, levelStr, 1.2f, {255, 255, 255, 255}); // Instructions const char* instr = "PRESS ENTER TO RESTART"; int iW=0, iH=0; pixelFont.measure(instr, 0.9f, iW, iH); pixelFont.draw(renderer, boxX + (boxW - iW) * 0.5f + contentOffsetX, boxY + 260 + contentOffsetY, instr, 0.9f, {255, 220, 0, 255}); const char* instr2 = "PRESS ESC FOR MENU"; int iW2=0, iH2=0; pixelFont.measure(instr2, 0.9f, iW2, iH2); pixelFont.draw(renderer, boxX + (boxW - iW2) * 0.5f + contentOffsetX, boxY + 290 + contentOffsetY, instr2, 0.9f, {255, 220, 0, 255}); } } break; } if (menuFadeAlpha > 0.0f) { SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderScale(renderer, 1.f, 1.f); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); Uint8 alpha = Uint8(std::clamp(menuFadeAlpha, 0.0f, 1.0f) * 255.0f); SDL_SetRenderDrawColor(renderer, 0, 0, 0, alpha); SDL_FRect fadeRect{0.f, 0.f, (float)winW, (float)winH}; SDL_RenderFillRect(renderer, &fadeRect); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); } if (gameplayCountdownActive && state == AppState::Playing) { // Switch to window coordinates for perfect centering in any resolution SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderScale(renderer, 1.f, 1.f); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); int cappedIndex = std::min(gameplayCountdownIndex, static_cast(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); const char* label = GAMEPLAY_COUNTDOWN_LABELS[cappedIndex]; bool isFinalCue = (cappedIndex == static_cast(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); float textScale = isFinalCue ? 4.5f : 5.0f; // Much bigger fonts for countdown int textW = 0, textH = 0; pixelFont.measure(label, textScale, textW, textH); // Center in actual window coordinates (works for any resolution/fullscreen) float textX = (winW - static_cast(textW)) * 0.5f; float textY = (winH - static_cast(textH)) * 0.5f; SDL_Color textColor = isFinalCue ? SDL_Color{255, 230, 90, 255} : SDL_Color{255, 255, 255, 255}; pixelFont.draw(renderer, textX, textY, label, textScale, textColor); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } if (showHelpOverlay) { SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); float contentOffsetX = 0.0f; float contentOffsetY = 0.0f; if (logicalScale > 0.0f) { float scaledW = LOGICAL_W * logicalScale; float scaledH = LOGICAL_H * logicalScale; contentOffsetX = (winW - scaledW) * 0.5f / logicalScale; contentOffsetY = (winH - scaledH) * 0.5f / logicalScale; } HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY); } SDL_RenderPresent(renderer); SDL_SetRenderScale(renderer, 1.f, 1.f); } if (logoTex) SDL_DestroyTexture(logoTex); if (mainScreenTex) SDL_DestroyTexture(mainScreenTex); levelBackgrounds.reset(); if (blocksTex) SDL_DestroyTexture(blocksTex); if (scorePanelTex) SDL_DestroyTexture(scorePanelTex); if (logoSmallTex) SDL_DestroyTexture(logoSmallTex); // Save settings on exit Settings::instance().save(); if (scoreLoader.joinable()) { scoreLoader.join(); if (!ctx.scores) { ctx.scores = &scores; } } if (menuTrackLoader.joinable()) { menuTrackLoader.join(); } lineEffect.shutdown(); Audio::instance().shutdown(); SoundEffectManager::instance().shutdown(); font.shutdown(); TTF_Quit(); SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return 0; }