// TetrisApp.cpp - Main application runtime split out from main.cpp. // // This file is intentionally "orchestration-heavy": it wires together SDL, audio, // asset loading, and the state machine. Keep gameplay mechanics in the gameplay/ // and states/ modules. #include "app/TetrisApp.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "app/AssetLoader.h" #include "app/BackgroundManager.h" #include "app/Fireworks.h" #include "app/TextureLoader.h" #include "audio/Audio.h" #include "audio/MenuWrappers.h" #include "audio/SoundEffect.h" #include "core/Config.h" #include "core/Settings.h" #include "core/state/StateManager.h" #include "gameplay/core/Game.h" #include "gameplay/effects/LineEffect.h" #include "graphics/effects/SpaceWarp.h" #include "graphics/effects/Starfield.h" #include "graphics/effects/Starfield3D.h" #include "graphics/renderers/GameRenderer.h" #include "graphics/renderers/RenderPrimitives.h" #include "graphics/ui/Font.h" #include "graphics/ui/HelpOverlay.h" #include "persistence/Scores.h" #include "states/LevelSelectorState.h" #include "states/LoadingManager.h" #include "states/LoadingState.h" #include "states/MenuState.h" #include "states/OptionsState.h" #include "states/PlayingState.h" #include "states/State.h" #include "ui/BottomMenu.h" #include "../resources/AssetPaths.h" #include "ui/MenuLayout.h" #include "utils/ImagePathResolver.h" // ---------- 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" 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 }}; struct TetrisApp::Impl { // Global collector for asset loading errors shown on the loading screen std::vector assetLoadErrors; std::mutex assetLoadErrorsMutex; // Loading counters for progress UI and debug overlay std::atomic totalLoadingTasks{0}; std::atomic loadedTasks{0}; std::string currentLoadingFile; std::mutex currentLoadingMutex; // Intro/Menu shared state (wired into StateContext as pointers) double logoAnimCounter = 0.0; bool showSettingsPopup = false; bool showHelpOverlay = false; bool showExitConfirmPopup = false; int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO bool musicEnabled = true; int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings bool isNewHighScore = false; std::string playerName; bool helpOverlayPausedGame = false; SDL_Window* window = nullptr; SDL_Renderer* renderer = nullptr; AssetLoader assetLoader; std::unique_ptr loadingManager; std::unique_ptr textureLoader; FontAtlas pixelFont; FontAtlas font; ScoreManager scores; std::atomic scoresLoadComplete{false}; std::jthread scoreLoader; std::jthread menuTrackLoader; Starfield starfield; Starfield3D starfield3D; SpaceWarp spaceWarp; SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward; bool warpAutoPilotEnabled = true; LineEffect lineEffect; SDL_Texture* logoTex = nullptr; SDL_Texture* logoSmallTex = nullptr; int logoSmallW = 0; int logoSmallH = 0; SDL_Texture* backgroundTex = nullptr; SDL_Texture* mainScreenTex = nullptr; int mainScreenW = 0; int mainScreenH = 0; SDL_Texture* blocksTex = nullptr; SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr; SDL_Texture* holdPanelTex = nullptr; BackgroundManager levelBackgrounds; int startLevelSelection = 0; // 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 loadingStarted{false}; std::atomic_bool loadingComplete{false}; std::atomic loadingStep{0}; std::unique_ptr game; std::vector singleSounds; std::vector doubleSounds; std::vector tripleSounds; std::vector tetrisSounds; bool suppressLineVoiceForLevelUp = false; AppState state = AppState::Loading; double loadingProgress = 0.0; Uint64 loadStart = 0; bool running = true; bool isFullscreen = false; bool leftHeld = false; bool rightHeld = false; double moveTimerMs = 0.0; double DAS = 170.0; double ARR = 40.0; SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; float logicalScale = 1.f; Uint64 lastMs = 0; enum class MenuFadePhase { None, FadeOut, FadeIn }; MenuFadePhase menuFadePhase = MenuFadePhase::None; double menuFadeClockMs = 0.0; float menuFadeAlpha = 0.0f; 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; double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; std::array GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; double gameplayBackgroundClockMs = 0.0; std::unique_ptr stateMgr; StateContext ctx{}; std::unique_ptr loadingState; std::unique_ptr menuState; std::unique_ptr optionsState; std::unique_ptr levelSelectorState; std::unique_ptr playingState; int init(); void runLoop(); void shutdown(); }; TetrisApp::TetrisApp() : impl_(std::make_unique()) { } TetrisApp::~TetrisApp() = default; int TetrisApp::run() { const int initRc = impl_->init(); if (initRc != 0) { impl_->shutdown(); return initRc; } impl_->runLoop(); impl_->shutdown(); return 0; } int TetrisApp::Impl::init() { // Initialize random seed for procedural effects srand(static_cast(SDL_GetTicks())); // Load settings Settings::instance().load(); // Sync shared 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; } window = SDL_CreateWindow("SpaceTris (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; } renderer = SDL_CreateRenderer(window, nullptr); if (!renderer) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError()); SDL_DestroyWindow(window); window = nullptr; 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.init(renderer); loadingManager = std::make_unique(&assetLoader); // Legacy image loader (used only as a fallback when AssetLoader misses) textureLoader = std::make_unique( loadedTasks, currentLoadingFile, currentLoadingMutex, assetLoadErrors, assetLoadErrorsMutex); // Load scores asynchronously but keep the worker alive until shutdown scoreLoader = std::jthread([this]() { scores.load(); scoresLoadComplete.store(true, std::memory_order_release); }); starfield.init(200, LOGICAL_W, LOGICAL_H); starfield3D.init(LOGICAL_W, LOGICAL_H, 200); spaceWarp.init(LOGICAL_W, LOGICAL_H, 420); spaceWarp.setFlightMode(warpFlightMode); warpAutoPilotEnabled = true; spaceWarp.setAutoPilotEnabled(true); // Initialize line clearing effects lineEffect.init(renderer); game = std::make_unique(startLevelSelection); game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); game->reset(startLevelSelection); // Define voice line banks for gameplay callbacks singleSounds = {"well_played", "smooth_clear", "great_move"}; doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; tripleSounds = {"impressive", "triple_strike"}; tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"}; suppressLineVoiceForLevelUp = false; auto playVoiceCue = [this](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); } }; game->setSoundCallback([this, playVoiceCue](int linesCleared) { if (linesCleared <= 0) { return; } SoundEffectManager::instance().playSound("clear_line", 1.0f); if (!suppressLineVoiceForLevelUp) { playVoiceCue(linesCleared); } suppressLineVoiceForLevelUp = false; }); game->setLevelUpCallback([this](int /*newLevel*/) { SoundEffectManager::instance().playSound("new_level", 1.0f); SoundEffectManager::instance().playSound("lets_go", 1.0f); suppressLineVoiceForLevelUp = true; }); state = AppState::Loading; loadingProgress = 0.0; loadStart = SDL_GetTicks(); running = true; isFullscreen = Settings::instance().isFullscreen(); leftHeld = false; rightHeld = false; moveTimerMs = 0; DAS = 170.0; ARR = 40.0; logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H}; logicalScale = 1.f; lastMs = SDL_GetPerformanceCounter(); menuFadePhase = MenuFadePhase::None; menuFadeClockMs = 0.0; menuFadeAlpha = 0.0f; MENU_PLAY_FADE_DURATION_MS = 450.0; menuFadeTarget = AppState::Menu; menuPlayCountdownArmed = false; gameplayCountdownActive = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; gameplayBackgroundClockMs = 0.0; // Instantiate state manager stateMgr = std::make_unique(state); // Prepare shared context for states ctx = StateContext{}; ctx.stateManager = stateMgr.get(); ctx.game = game.get(); ctx.scores = nullptr; 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 = [this](bool enable) { SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0); isFullscreen = enable; }; ctx.queryFullscreen = [this]() -> bool { return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; }; ctx.requestQuit = [this]() { running = false; }; auto beginStateFade = [this](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) { if (game) { game->setPaused(false); } } }; auto startMenuPlayTransition = [this, beginStateFade]() { 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 = [this, startMenuPlayTransition, beginStateFade](AppState targetState) { if (!ctx.stateManager) { return; } if (targetState == AppState::Playing) { startMenuPlayTransition(); return; } beginStateFade(targetState, false); }; ctx.requestFadeTransition = requestStateFade; loadingState = std::make_unique(ctx); menuState = std::make_unique(ctx); optionsState = std::make_unique(ctx); levelSelectorState = std::make_unique(ctx); playingState = std::make_unique(ctx); stateMgr->registerHandler(AppState::Loading, [this](const SDL_Event& e){ loadingState->handleEvent(e); }); stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); }); stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); }); stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); stateMgr->registerHandler(AppState::Options, [this](const SDL_Event& e){ optionsState->handleEvent(e); }); stateMgr->registerOnEnter(AppState::Options, [this](){ optionsState->onEnter(); }); stateMgr->registerOnExit(AppState::Options, [this](){ optionsState->onExit(); }); stateMgr->registerHandler(AppState::LevelSelector, [this](const SDL_Event& e){ levelSelectorState->handleEvent(e); }); stateMgr->registerOnEnter(AppState::LevelSelector, [this](){ levelSelectorState->onEnter(); }); stateMgr->registerOnExit(AppState::LevelSelector, [this](){ levelSelectorState->onExit(); }); stateMgr->registerHandler(AppState::Playing, [this](const SDL_Event& e){ playingState->handleEvent(e); }); stateMgr->registerOnEnter(AppState::Playing, [this](){ playingState->onEnter(); }); stateMgr->registerOnExit(AppState::Playing, [this](){ playingState->onExit(); }); loadingState->onEnter(); loadingStarted.store(true); return 0; } void TetrisApp::Impl::runLoop() { auto ensureScoresLoaded = [this]() { if (scoreLoader.joinable()) { scoreLoader.join(); } if (!ctx.scores) { ctx.scores = &scores; } }; auto startMenuPlayTransition = [this]() { if (ctx.startPlayTransition) { ctx.startPlayTransition(); } }; auto requestStateFade = [this](AppState targetState) { if (ctx.requestFadeTransition) { ctx.requestFadeTransition(targetState); } }; while (running) { if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) { ensureScoresLoaded(); } int winW = 0, winH = 0; SDL_GetWindowSize(window, &winW, &winH); logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H); if (logicalScale <= 0) logicalScale = 1.f; logicalVP.w = winW; logicalVP.h = winH; logicalVP.x = 0; logicalVP.y = 0; SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) running = false; else { 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); state = stateMgr->getState(); } 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) { SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled()); Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); } const bool helpToggleKey = (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu); if (helpToggleKey) { 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 (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) { showHelpOverlay = false; if (state == AppState::Playing && helpOverlayPausedGame) { game->setPaused(false); } helpOverlayPausedGame = false; } 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"); } } 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) { game->reset(startLevelSelection); state = AppState::Playing; stateMgr->setState(state); } else if (e.key.scancode == SDL_SCANCODE_ESCAPE) { state = AppState::Menu; stateMgr->setState(state); } } } 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) { if (showSettingsPopup) { showSettingsPopup = false; } else { ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); hoveredButton = menuInput.hoveredIndex; if (menuInput.activated) { switch (*menuInput.activated) { case ui::BottomMenuItem::Play: startMenuPlayTransition(); break; case ui::BottomMenuItem::Level: requestStateFade(AppState::LevelSelector); break; case ui::BottomMenuItem::Options: requestStateFade(AppState::Options); break; case ui::BottomMenuItem::Help: if (menuState) menuState->showHelpPanel(true); break; case ui::BottomMenuItem::About: if (menuState) menuState->showAboutPanel(true); break; case ui::BottomMenuItem::Exit: showExitConfirmPopup = true; exitPopupSelectedButton = 1; break; } } 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) { float lx2 = (mx - logicalVP.x) / logicalScale; float ly2 = (my - logicalVP.y) / logicalScale; 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 localX = lx2 - contentOffsetX; float localY = ly2 - contentOffsetY; float popupW = 400, popupH = 200; float popupX = (LOGICAL_W - popupW) / 2.0f; float popupY = (LOGICAL_H - popupH) / 2.0f; 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) { if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) { showExitConfirmPopup = false; game->reset(startLevelSelection); state = AppState::Menu; stateMgr->setState(state); } else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) { showExitConfirmPopup = false; game->setPaused(false); } } else { 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) { ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); hoveredButton = menuInput.hoveredIndex; } } } } } Uint64 now = SDL_GetPerformanceCounter(); double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); lastMs = now; 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]; if (state == AppState::Playing) game->setSoftDropping(down && !game->isPaused()); else game->setSoftDropping(false); 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); if (musicLoadingStarted && !musicLoaded) { currentTrackLoading = Audio::instance().getLoadedTrackCount(); if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) { Audio::instance().shuffle(); musicLoaded = true; } } if (state == AppState::Playing) { if (!game->isPaused()) { game->tickGravity(frameMs); game->updateElapsedTime(); if (lineEffect.isActive()) { if (lineEffect.update(frameMs / 1000.0f)) { game->clearCompletedLines(); } } } if (game->isGameOver()) { if (game->score() > 0) { isNewHighScore = true; playerName.clear(); 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; if (loadingStarted.load() && !loadingComplete.load()) { static bool queuedTextures = false; static std::vector queuedPaths; if (!queuedTextures) { queuedTextures = true; constexpr int baseTasks = 25; totalLoadingTasks.store(baseTasks); loadedTasks.store(0); { std::lock_guard lk(assetLoadErrorsMutex); assetLoadErrors.clear(); } { std::lock_guard lk(currentLoadingMutex); currentLoadingFile.clear(); } 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++; } totalLoadingTasks.store(baseTasks + totalTracks); if (totalTracks > 0) { Audio::instance().startBackgroundLoading(); musicLoadingStarted = true; } else { musicLoaded = true; } pixelFont.init(AssetPath::resolveWithBase(Assets::FONT_ORBITRON), 22); loadedTasks.fetch_add(1); font.init(AssetPath::resolveWithBase(Assets::FONT_EXO2), 20); loadedTasks.fetch_add(1); queuedPaths = { Assets::LOGO, Assets::LOGO, Assets::MAIN_SCREEN, Assets::BLOCKS_SPRITE, Assets::PANEL_SCORE, Assets::PANEL_STATS, Assets::NEXT_PANEL, Assets::HOLD_PANEL }; for (auto &p : queuedPaths) { loadingManager->queueTexture(p); } queuedTextureCount = static_cast(queuedPaths.size()); SoundEffectManager::instance().init(); loadedTasks.fetch_add(1); 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(currentLoadingMutex); currentLoadingFile = basePath; } std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" }); if (!resolved.empty()) { SoundEffectManager::instance().loadSound(id, resolved); } loadedTasks.fetch_add(1); { std::lock_guard lk(currentLoadingMutex); currentLoadingFile.clear(); } } } bool texturesDone = loadingManager->update(); if (texturesDone) { logoTex = assetLoader.getTexture(Assets::LOGO); logoSmallTex = assetLoader.getTexture(Assets::LOGO); mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); blocksTex = assetLoader.getTexture(Assets::BLOCKS_SPRITE); scorePanelTex = assetLoader.getTexture(Assets::PANEL_SCORE); statisticsPanelTex = assetLoader.getTexture(Assets::PANEL_STATS); nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL); holdPanelTex = assetLoader.getTexture(Assets::HOLD_PANEL); 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)); } }; ensureTextureSize(logoSmallTex, logoSmallW, logoSmallH); ensureTextureSize(mainScreenTex, mainScreenW, mainScreenH); auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) { if (!outTex) { SDL_Texture* loaded = textureLoader->loadFromImage(renderer, p, outW, outH); if (loaded) { outTex = loaded; assetLoader.adoptTexture(p, loaded); } } }; legacyLoad(Assets::LOGO, logoTex); legacyLoad(Assets::LOGO, logoSmallTex, &logoSmallW, &logoSmallH); legacyLoad(Assets::MAIN_SCREEN, mainScreenTex, &mainScreenW, &mainScreenH); legacyLoad(Assets::BLOCKS_SPRITE, blocksTex); legacyLoad(Assets::PANEL_SCORE, scorePanelTex); legacyLoad(Assets::PANEL_STATS, statisticsPanelTex); legacyLoad(Assets::NEXT_PANEL, nextPanelTex); legacyLoad(Assets::HOLD_PANEL, holdPanelTex); 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); // Ensure the generated fallback texture is cleaned up with other assets. assetLoader.adoptTexture(Assets::BLOCKS_SPRITE, blocksTex); } if (musicLoaded) { loadingComplete.store(true); } } } const int totalTasks = totalLoadingTasks.load(std::memory_order_acquire); const int musicDone = std::min(totalTracks, currentTrackLoading); int doneTasks = loadedTasks.load(std::memory_order_acquire) + musicDone; 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 { 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) { 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; } if (state == AppState::Menu) { Audio::instance().playMenuMusic(); } else { Audio::instance().playGameMusic(); } musicStarted = true; } } static AppState previousState = AppState::Loading; if (state != previousState && musicStarted) { if (state == AppState::Menu && previousState == AppState::Playing) { Audio::instance().playMenuMusic(); } else if (state == AppState::Playing && previousState == AppState::Menu) { Audio::instance().playGameMusic(); } } previousState = state; 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)); } levelBackgrounds.update(float(frameMs)); if (state == AppState::Menu) { logoAnimCounter += frameMs * 0.0008; } 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; } 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.holdPanelTex = holdPanelTex; 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); } SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_RenderClear(renderer); 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) { starfield3D.draw(renderer); } else if (state == AppState::Menu) { spaceWarp.draw(renderer, 1.0f); } else if (state == AppState::LevelSelector || state == AppState::Options) { // No background texture } else { starfield.draw(renderer); } SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); switch (state) { case AppState::Loading: { 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) { RenderPrimitives::fillRect(renderer, x + contentOffsetX, y + contentOffsetY, w, h, c); }; const bool isLimitedHeight = LOGICAL_H < 450; const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0; const float loadingTextHeight = 20; const float barHeight = 20; const float barPaddingVertical = isLimitedHeight ? 15 : 35; const float percentTextHeight = 24; const float spacingBetweenElements = isLimitedHeight ? 5 : 15; const float totalContentHeight = logoHeight + (logoHeight > 0 ? spacingBetweenElements : 0) + loadingTextHeight + barPaddingVertical + barHeight + spacingBetweenElements + percentTextHeight; float currentY = (LOGICAL_H - totalContentHeight) / 2.0f; if (logoTex) { const int lw = 872, lh = 273; 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; } const char* loadingText = "LOADING"; float textWidth = strlen(loadingText) * 12.0f; float textX = (LOGICAL_W - textWidth) / 2.0f; pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255}); currentY += loadingTextHeight + barPaddingVertical; const int barW = 400, barH = 20; const int bx = (LOGICAL_W - barW) / 2; drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255}); drawRect(bx, currentY, barW, barH, {34, 34, 34, 255}); drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255}); currentY += barH + spacingBetweenElements; int percentage = int(loadingProgress * 100); char percentText[16]; std::snprintf(percentText, sizeof(percentText), "%d%%", percentage); float percentWidth = strlen(percentText) * 12.0f; float percentX = (LOGICAL_W - percentWidth) / 2.0f; pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255}); { std::lock_guard lk(assetLoadErrorsMutex); const int maxShow = 5; int count = static_cast(assetLoadErrors.size()); if (count > 0) { int start = std::max(0, count - maxShow); float errY = currentY + spacingBetweenElements + 8.0f; std::string latest = assetLoadErrors.back(); std::string shortTitle = "SpaceTris - 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()); FILE* tf = fopen("tetris_trace.log", "a"); if (tf) { fprintf(tf, "Loading error: %s\n", assetLoadErrors.back().c_str()); fclose(tf); } for (int i = start; i < count; ++i) { const std::string& msg = assetLoadErrors[i]; 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; } } } if (Settings::instance().isDebugEnabled()) { std::string cur; { std::lock_guard lk(currentLoadingMutex); cur = currentLoadingFile; } char buf[128]; int loaded = loadedTasks.load(); int total = 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: if (!mainScreenTex) { mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); } if (!mainScreenTex) { SDL_Texture* loaded = textureLoader->loadFromImage(renderer, Assets::MAIN_SCREEN, &mainScreenW, &mainScreenH); if (loaded) { assetLoader.adoptTexture(Assets::MAIN_SCREEN, loaded); mainScreenTex = loaded; } } if (menuState) { menuState->drawMainButtonNormally = false; menuState->render(renderer, logicalScale, logicalVP); } 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_SetTextureScaleMode(mainScreenTex, SDL_SCALEMODE_LINEAR); SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst); } SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); } if (menuState) { menuState->renderMainButtonTop(renderer, logicalScale, logicalVP); } break; case AppState::Options: optionsState->render(renderer, logicalScale, logicalVP); break; case AppState::LevelSelector: 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: GameRenderer::renderPlayingState( renderer, game.get(), &pixelFont, &lineEffect, blocksTex, ctx.statisticsPanelTex, scorePanelTex, nextPanelTex, holdPanelTex, (float)LOGICAL_W, (float)LOGICAL_H, logicalScale, (float)winW, (float)winH ); { SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH}; SDL_RenderFillRect(renderer, &fullWin); SDL_SetRenderViewport(renderer, &logicalVP); SDL_SetRenderScale(renderer, logicalScale, logicalScale); 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; float boxW = 500.0f; float boxH = 350.0f; float boxX = (LOGICAL_W - boxW) * 0.5f; float boxY = (LOGICAL_H - boxH) * 0.5f; SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH}; SDL_RenderFillRect(renderer, &boxRect); SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255); SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6}; SDL_RenderFillRect(renderer, &borderRect); SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); SDL_RenderFillRect(renderer, &boxRect); 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}); 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) { 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}); 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); 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; 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}); } 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 { 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}); 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}); 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) { 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; int textW = 0, textH = 0; pixelFont.measure(label, textScale, textW, textH); 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); } } void TetrisApp::Impl::shutdown() { Settings::instance().save(); // BackgroundManager owns its own textures. levelBackgrounds.reset(); // All textures are owned by AssetLoader (including legacy fallbacks adopted above). logoTex = nullptr; logoSmallTex = nullptr; backgroundTex = nullptr; mainScreenTex = nullptr; blocksTex = nullptr; scorePanelTex = nullptr; statisticsPanelTex = nullptr; nextPanelTex = nullptr; if (scoreLoader.joinable()) { scoreLoader.join(); if (!ctx.scores) { ctx.scores = &scores; } } if (menuTrackLoader.joinable()) { menuTrackLoader.join(); } lineEffect.shutdown(); Audio::instance().shutdown(); SoundEffectManager::instance().shutdown(); // Destroy textures before tearing down the renderer/window. assetLoader.shutdown(); pixelFont.shutdown(); font.shutdown(); TTF_Quit(); if (renderer) { SDL_DestroyRenderer(renderer); renderer = nullptr; } if (window) { SDL_DestroyWindow(window); window = nullptr; } SDL_Quit(); }