diff --git a/CMakeLists.txt b/CMakeLists.txt index cd046df..b27dce9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ find_package(nlohmann_json CONFIG REQUIRED) set(TETRIS_SOURCES src/main.cpp + src/app/TetrisApp.cpp src/gameplay/core/Game.cpp src/core/GravityManager.cpp src/core/state/StateManager.cpp @@ -67,6 +68,7 @@ set(TETRIS_SOURCES src/states/PlayingState.cpp ) + if(APPLE) set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns") if(EXISTS "${APP_ICON}") diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp new file mode 100644 index 0000000..dc9d8bc --- /dev/null +++ b/src/app/TetrisApp.cpp @@ -0,0 +1,1720 @@ +// 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 "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; + + 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/fonts/Orbitron.ttf"), 22); + loadedTasks.fetch_add(1); + font.init(AssetPath::resolveWithBase("assets/fonts/Exo2.ttf"), 20); + loadedTasks.fetch_add(1); + + queuedPaths = { + "assets/images/spacetris.png", + "assets/images/spacetris.png", + "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()); + + 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/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)); + } + }; + + 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) { + outTex = textureLoader->loadFromImage(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 (!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); + } + + 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.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 = textureLoader->loadFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH); + } + 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, + (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(); + + if (logoTex) { + SDL_DestroyTexture(logoTex); + logoTex = nullptr; + } + if (mainScreenTex) { + SDL_DestroyTexture(mainScreenTex); + mainScreenTex = nullptr; + } + levelBackgrounds.reset(); + if (blocksTex) { + SDL_DestroyTexture(blocksTex); + blocksTex = nullptr; + } + if (scorePanelTex) { + SDL_DestroyTexture(scorePanelTex); + scorePanelTex = nullptr; + } + if (logoSmallTex) { + SDL_DestroyTexture(logoSmallTex); + logoSmallTex = 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(); + font.shutdown(); + + TTF_Quit(); + + if (renderer) { + SDL_DestroyRenderer(renderer); + renderer = nullptr; + } + if (window) { + SDL_DestroyWindow(window); + window = nullptr; + } + SDL_Quit(); +} diff --git a/src/app/TetrisApp.h b/src/app/TetrisApp.h new file mode 100644 index 0000000..1b795d0 --- /dev/null +++ b/src/app/TetrisApp.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// TetrisApp is the top-level application orchestrator. +// +// Responsibilities: +// - SDL/TTF init + shutdown +// - Asset/music loading + loading screen +// - Main loop + state transitions +// +// It uses a PIMPL to keep `TetrisApp.h` light (faster builds) and to avoid leaking +// SDL-heavy includes into every translation unit. +class TetrisApp { +public: + TetrisApp(); + ~TetrisApp(); + + TetrisApp(const TetrisApp&) = delete; + TetrisApp& operator=(const TetrisApp&) = delete; + + // Runs the application until exit is requested. + // Returns a non-zero exit code on initialization failure. + int run(); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/src/core/Config.h b/src/core/Config.h index 500c4ca..83c2193 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -14,7 +14,7 @@ namespace Config { namespace Window { constexpr int DEFAULT_WIDTH = 1200; constexpr int DEFAULT_HEIGHT = 1000; - constexpr const char* DEFAULT_TITLE = "Tetris (SDL3)"; + constexpr const char* DEFAULT_TITLE = "SpaceTris (SDL3)"; constexpr bool DEFAULT_VSYNC = true; } diff --git a/src/main.cpp b/src/main.cpp index 7a417c8..e88774b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,1953 +1,15 @@ -// main.cpp - Application orchestration (initialization, loop, UI states) -// High-level only: delegates Tetris logic, scores, background, font rendering. +// main.cpp - Thin entrypoint. +// +// The full SDL initialization, loading screen, state machine, game loop, and shutdown +// are intentionally kept out of this file (see `TetrisApp`) to keep the entrypoint +// small and keep orchestration separate from gameplay/state code. -#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 "app/TextureLoader.h" -#include "states/LoadingManager.h" -#include "utils/ImagePathResolver.h" -#include "graphics/renderers/GameRenderer.h" -#include "graphics/renderers/RenderPrimitives.h" -#include "core/Config.h" -#include "core/Settings.h" -#include "ui/MenuLayout.h" -#include "ui/BottomMenu.h" - -// 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 - -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; - -// 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; - -namespace { - -struct AppRuntime { - 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; - - 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; -}; - -static int initApp(AppRuntime& app) -{ - // 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; - } - - app.window = SDL_CreateWindow("Tetris (SDL3)", LOGICAL_W, LOGICAL_H, windowFlags); - if (!app.window) - { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateWindow failed: %s", SDL_GetError()); - TTF_Quit(); - SDL_Quit(); - return 1; - } - app.renderer = SDL_CreateRenderer(app.window, nullptr); - if (!app.renderer) - { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError()); - SDL_DestroyWindow(app.window); - app.window = nullptr; - TTF_Quit(); - SDL_Quit(); - return 1; - } - SDL_SetRenderVSync(app.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) - app.assetLoader.init(app.renderer); - app.loadingManager = std::make_unique(&app.assetLoader); - - // Legacy image loader (used only as a fallback when AssetLoader misses) - app.textureLoader = std::make_unique( - g_loadedTasks, - g_currentLoadingFile, - g_currentLoadingMutex, - g_assetLoadErrors, - g_assetLoadErrorsMutex); - - // Load scores asynchronously but keep the worker alive until shutdown to avoid lifetime issues - app.scoreLoader = std::jthread([&app]() { - app.scores.load(); - app.scoresLoadComplete.store(true, std::memory_order_release); - }); - - app.starfield.init(200, LOGICAL_W, LOGICAL_H); - app.starfield3D.init(LOGICAL_W, LOGICAL_H, 200); - app.spaceWarp.init(LOGICAL_W, LOGICAL_H, 420); - app.spaceWarp.setFlightMode(app.warpFlightMode); - app.warpAutoPilotEnabled = true; - app.spaceWarp.setAutoPilotEnabled(true); - - // Initialize line clearing effects - app.lineEffect.init(app.renderer); - - app.game = std::make_unique(app.startLevelSelection); - // Apply global gravity speed multiplier from config - app.game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); - app.game->reset(app.startLevelSelection); - - // Define voice line banks for gameplay callbacks - app.singleSounds = {"well_played", "smooth_clear", "great_move"}; - app.doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; - app.tripleSounds = {"impressive", "triple_strike"}; - app.tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"}; - app.suppressLineVoiceForLevelUp = false; - - auto playVoiceCue = [&app](int linesCleared) { - const std::vector* bank = nullptr; - switch (linesCleared) { - case 1: bank = &app.singleSounds; break; - case 2: bank = &app.doubleSounds; break; - case 3: bank = &app.tripleSounds; break; - default: - if (linesCleared >= 4) { - bank = &app.tetrisSounds; - } - break; - } - if (bank && !bank->empty()) { - SoundEffectManager::instance().playRandomSound(*bank, 1.0f); - } - }; - - // Set up sound effect callbacks - app.game->setSoundCallback([&app, 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 (!app.suppressLineVoiceForLevelUp) { - playVoiceCue(linesCleared); - } - app.suppressLineVoiceForLevelUp = false; - }); - - app.game->setLevelUpCallback([&app](int newLevel) { - SoundEffectManager::instance().playSound("new_level", 1.0f); - SoundEffectManager::instance().playSound("lets_go", 1.0f); // Existing voice line - app.suppressLineVoiceForLevelUp = true; - }); - - app.state = AppState::Loading; - app.loadingProgress = 0.0; - app.loadStart = SDL_GetTicks(); - app.running = true; - app.isFullscreen = Settings::instance().isFullscreen(); - app.leftHeld = false; - app.rightHeld = false; - app.moveTimerMs = 0; - app.DAS = 170.0; - app.ARR = 40.0; - app.logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H}; - app.logicalScale = 1.f; - app.lastMs = SDL_GetPerformanceCounter(); - - app.menuFadePhase = AppRuntime::MenuFadePhase::None; - app.menuFadeClockMs = 0.0; - app.menuFadeAlpha = 0.0f; - app.MENU_PLAY_FADE_DURATION_MS = 450.0; - app.menuFadeTarget = AppState::Menu; - app.menuPlayCountdownArmed = false; - app.gameplayCountdownActive = false; - app.gameplayCountdownElapsed = 0.0; - app.gameplayCountdownIndex = 0; - app.GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; - app.GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; - app.gameplayBackgroundClockMs = 0.0; - - // Instantiate state manager - app.stateMgr = std::make_unique(app.state); - - // Prepare shared context for states - app.ctx = StateContext{}; - app.ctx.stateManager = app.stateMgr.get(); - app.ctx.game = app.game.get(); - app.ctx.scores = nullptr; // populated once async load finishes - app.ctx.starfield = &app.starfield; - app.ctx.starfield3D = &app.starfield3D; - app.ctx.font = &app.font; - app.ctx.pixelFont = &app.pixelFont; - app.ctx.lineEffect = &app.lineEffect; - app.ctx.logoTex = app.logoTex; - app.ctx.logoSmallTex = app.logoSmallTex; - app.ctx.logoSmallW = app.logoSmallW; - app.ctx.logoSmallH = app.logoSmallH; - app.ctx.backgroundTex = nullptr; - app.ctx.blocksTex = app.blocksTex; - app.ctx.scorePanelTex = app.scorePanelTex; - app.ctx.statisticsPanelTex = app.statisticsPanelTex; - app.ctx.nextPanelTex = app.nextPanelTex; - app.ctx.mainScreenTex = app.mainScreenTex; - app.ctx.mainScreenW = app.mainScreenW; - app.ctx.mainScreenH = app.mainScreenH; - app.ctx.musicEnabled = &musicEnabled; - app.ctx.startLevelSelection = &app.startLevelSelection; - app.ctx.hoveredButton = &hoveredButton; - app.ctx.showSettingsPopup = &showSettingsPopup; - app.ctx.showHelpOverlay = &showHelpOverlay; - app.ctx.showExitConfirmPopup = &showExitConfirmPopup; - app.ctx.exitPopupSelectedButton = &exitPopupSelectedButton; - app.ctx.gameplayCountdownActive = &app.gameplayCountdownActive; - app.ctx.menuPlayCountdownArmed = &app.menuPlayCountdownArmed; - app.ctx.playerName = &playerName; - app.ctx.fullscreenFlag = &app.isFullscreen; - app.ctx.applyFullscreen = [&app](bool enable) { - SDL_SetWindowFullscreen(app.window, enable ? SDL_WINDOW_FULLSCREEN : 0); - app.isFullscreen = enable; - }; - app.ctx.queryFullscreen = [&app]() -> bool { - return (SDL_GetWindowFlags(app.window) & SDL_WINDOW_FULLSCREEN) != 0; - }; - app.ctx.requestQuit = [&app]() { - app.running = false; - }; - - auto beginStateFade = [&app](AppState targetState, bool armGameplayCountdown) { - if (!app.ctx.stateManager) { - return; - } - if (app.state == targetState) { - return; - } - if (app.menuFadePhase != AppRuntime::MenuFadePhase::None) { - return; - } - - app.menuFadePhase = AppRuntime::MenuFadePhase::FadeOut; - app.menuFadeClockMs = 0.0; - app.menuFadeAlpha = 0.0f; - app.menuFadeTarget = targetState; - app.menuPlayCountdownArmed = armGameplayCountdown; - app.gameplayCountdownActive = false; - app.gameplayCountdownIndex = 0; - app.gameplayCountdownElapsed = 0.0; - - if (!armGameplayCountdown) { - if (app.game) { - app.game->setPaused(false); - } - } - }; - - auto startMenuPlayTransition = [&app, beginStateFade]() { - if (!app.ctx.stateManager) { - return; - } - if (app.state != AppState::Menu) { - app.state = AppState::Playing; - app.ctx.stateManager->setState(app.state); - return; - } - beginStateFade(AppState::Playing, true); - }; - app.ctx.startPlayTransition = startMenuPlayTransition; - - auto requestStateFade = [&app, startMenuPlayTransition, beginStateFade](AppState targetState) { - if (!app.ctx.stateManager) { - return; - } - if (targetState == AppState::Playing) { - startMenuPlayTransition(); - return; - } - beginStateFade(targetState, false); - }; - app.ctx.requestFadeTransition = requestStateFade; - - // Instantiate state objects - app.loadingState = std::make_unique(app.ctx); - app.menuState = std::make_unique(app.ctx); - app.optionsState = std::make_unique(app.ctx); - app.levelSelectorState = std::make_unique(app.ctx); - app.playingState = std::make_unique(app.ctx); - - // Register handlers and lifecycle hooks - app.stateMgr->registerHandler(AppState::Loading, [&app](const SDL_Event& e){ app.loadingState->handleEvent(e); }); - app.stateMgr->registerOnEnter(AppState::Loading, [&app](){ app.loadingState->onEnter(); app.loadingStarted.store(true); }); - app.stateMgr->registerOnExit(AppState::Loading, [&app](){ app.loadingState->onExit(); }); - - app.stateMgr->registerHandler(AppState::Menu, [&app](const SDL_Event& e){ app.menuState->handleEvent(e); }); - app.stateMgr->registerOnEnter(AppState::Menu, [&app](){ app.menuState->onEnter(); }); - app.stateMgr->registerOnExit(AppState::Menu, [&app](){ app.menuState->onExit(); }); - - app.stateMgr->registerHandler(AppState::Options, [&app](const SDL_Event& e){ app.optionsState->handleEvent(e); }); - app.stateMgr->registerOnEnter(AppState::Options, [&app](){ app.optionsState->onEnter(); }); - app.stateMgr->registerOnExit(AppState::Options, [&app](){ app.optionsState->onExit(); }); - - app.stateMgr->registerHandler(AppState::LevelSelector, [&app](const SDL_Event& e){ app.levelSelectorState->handleEvent(e); }); - app.stateMgr->registerOnEnter(AppState::LevelSelector, [&app](){ app.levelSelectorState->onEnter(); }); - app.stateMgr->registerOnExit(AppState::LevelSelector, [&app](){ app.levelSelectorState->onExit(); }); - - app.stateMgr->registerHandler(AppState::Playing, [&app](const SDL_Event& e){ app.playingState->handleEvent(e); }); - app.stateMgr->registerOnEnter(AppState::Playing, [&app](){ app.playingState->onEnter(); }); - app.stateMgr->registerOnExit(AppState::Playing, [&app](){ app.playingState->onExit(); }); - - // Manually trigger the initial Loading state's onEnter - app.loadingState->onEnter(); - app.loadingStarted.store(true); - - return 0; -} - -static void runGameLoop(AppRuntime& app) -{ - using MenuFadePhase = AppRuntime::MenuFadePhase; - - SDL_Window* window = app.window; - SDL_Renderer* renderer = app.renderer; - AssetLoader& assetLoader = app.assetLoader; - LoadingManager& loadingManager = *app.loadingManager; - TextureLoader& textureLoader = *app.textureLoader; - FontAtlas& pixelFont = app.pixelFont; - FontAtlas& font = app.font; - ScoreManager& scores = app.scores; - std::atomic& scoresLoadComplete = app.scoresLoadComplete; - std::jthread& scoreLoader = app.scoreLoader; - std::jthread& menuTrackLoader = app.menuTrackLoader; - Starfield& starfield = app.starfield; - Starfield3D& starfield3D = app.starfield3D; - SpaceWarp& spaceWarp = app.spaceWarp; - SpaceWarpFlightMode& warpFlightMode = app.warpFlightMode; - bool& warpAutoPilotEnabled = app.warpAutoPilotEnabled; - LineEffect& lineEffect = app.lineEffect; - SDL_Texture*& logoTex = app.logoTex; - int& logoSmallW = app.logoSmallW; - int& logoSmallH = app.logoSmallH; - SDL_Texture*& logoSmallTex = app.logoSmallTex; - SDL_Texture*& backgroundTex = app.backgroundTex; - int& mainScreenW = app.mainScreenW; - int& mainScreenH = app.mainScreenH; - SDL_Texture*& mainScreenTex = app.mainScreenTex; - BackgroundManager& levelBackgrounds = app.levelBackgrounds; - int& startLevelSelection = app.startLevelSelection; - SDL_Texture*& blocksTex = app.blocksTex; - SDL_Texture*& scorePanelTex = app.scorePanelTex; - SDL_Texture*& statisticsPanelTex = app.statisticsPanelTex; - SDL_Texture*& nextPanelTex = app.nextPanelTex; - int& totalTracks = app.totalTracks; - int& currentTrackLoading = app.currentTrackLoading; - bool& musicLoaded = app.musicLoaded; - bool& musicStarted = app.musicStarted; - bool& musicLoadingStarted = app.musicLoadingStarted; - std::atomic_bool& g_loadingStarted = app.loadingStarted; - std::atomic_bool& g_loadingComplete = app.loadingComplete; - std::atomic& g_loadingStep = app.loadingStep; - Game& game = *app.game; - bool& suppressLineVoiceForLevelUp = app.suppressLineVoiceForLevelUp; - AppState& state = app.state; - double& loadingProgress = app.loadingProgress; - Uint64& loadStart = app.loadStart; - bool& running = app.running; - bool& isFullscreen = app.isFullscreen; - bool& leftHeld = app.leftHeld; - bool& rightHeld = app.rightHeld; - double& moveTimerMs = app.moveTimerMs; - const double DAS = app.DAS; - const double ARR = app.ARR; - SDL_Rect& logicalVP = app.logicalVP; - float& logicalScale = app.logicalScale; - Uint64& lastMs = app.lastMs; - AppRuntime::MenuFadePhase& menuFadePhase = app.menuFadePhase; - double& menuFadeClockMs = app.menuFadeClockMs; - float& menuFadeAlpha = app.menuFadeAlpha; - const double MENU_PLAY_FADE_DURATION_MS = app.MENU_PLAY_FADE_DURATION_MS; - AppState& menuFadeTarget = app.menuFadeTarget; - bool& menuPlayCountdownArmed = app.menuPlayCountdownArmed; - bool& gameplayCountdownActive = app.gameplayCountdownActive; - double& gameplayCountdownElapsed = app.gameplayCountdownElapsed; - int& gameplayCountdownIndex = app.gameplayCountdownIndex; - const double GAMEPLAY_COUNTDOWN_STEP_MS = app.GAMEPLAY_COUNTDOWN_STEP_MS; - const std::array& GAMEPLAY_COUNTDOWN_LABELS = app.GAMEPLAY_COUNTDOWN_LABELS; - double& gameplayBackgroundClockMs = app.gameplayBackgroundClockMs; - StateManager& stateMgr = *app.stateMgr; - StateContext& ctx = app.ctx; - auto& loadingState = app.loadingState; - auto& menuState = app.menuState; - auto& optionsState = app.optionsState; - auto& levelSelectorState = app.levelSelectorState; - auto& playingState = app.playingState; - - auto ensureScoresLoaded = [&]() { - if (scoreLoader.joinable()) { - scoreLoader.join(); - } - if (!ctx.scores) { - ctx.scores = &scores; - } - }; - - auto startMenuPlayTransition = [&]() { - if (ctx.startPlayTransition) { - ctx.startPlayTransition(); - } - }; - - auto requestStateFade = [&](AppState targetState) { - if (ctx.requestFadeTransition) { - ctx.requestFadeTransition(targetState); - } - }; - - // 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()); - } - // Help overlay toggle: F1 (keep it disabled on Loading/Menu) - 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 help overlay is visible and the user presses ESC, close help. - if (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) { - showHelpOverlay = false; - // Unpause only if the overlay paused the game. - 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"); - } - } - - // 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 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: - // HELP - show inline help HUD in the MenuState - if (menuState) menuState->showHelpPanel(true); - break; - case ui::BottomMenuItem::About: - // ABOUT - show inline about HUD in the MenuState - if (menuState) menuState->showAboutPanel(true); - break; - case ui::BottomMenuItem::Exit: - showExitConfirmPopup = true; - exitPopupSelectedButton = 1; - break; - } - } - - // 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 }; - auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); - hoveredButton = menuInput.hoveredIndex; - } - } - } - } - } - - // --- 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 = textureLoader.loadFromImage(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) - { RenderPrimitives::fillRect(renderer, x + contentOffsetX, y + contentOffsetY, w, h, c); }; - - // 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 = textureLoader.loadFromImage(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); - // Use linear filtering for the scaled overlay to avoid single-pixel aliasing - // artifacts (e.g. a tiny static dot) when the PNG is resized. - SDL_SetTextureScaleMode(mainScreenTex, SDL_SCALEMODE_LINEAR); - 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); - } -} - -static void shutdownApp(AppRuntime& app) -{ - // Save settings on exit - Settings::instance().save(); - - if (app.logoTex) { - SDL_DestroyTexture(app.logoTex); - app.logoTex = nullptr; - } - if (app.mainScreenTex) { - SDL_DestroyTexture(app.mainScreenTex); - app.mainScreenTex = nullptr; - } - app.levelBackgrounds.reset(); - if (app.blocksTex) { - SDL_DestroyTexture(app.blocksTex); - app.blocksTex = nullptr; - } - if (app.scorePanelTex) { - SDL_DestroyTexture(app.scorePanelTex); - app.scorePanelTex = nullptr; - } - if (app.logoSmallTex) { - SDL_DestroyTexture(app.logoSmallTex); - app.logoSmallTex = nullptr; - } - - if (app.scoreLoader.joinable()) { - app.scoreLoader.join(); - if (!app.ctx.scores) { - app.ctx.scores = &app.scores; - } - } - if (app.menuTrackLoader.joinable()) { - app.menuTrackLoader.join(); - } - - app.lineEffect.shutdown(); - Audio::instance().shutdown(); - SoundEffectManager::instance().shutdown(); - app.font.shutdown(); - - TTF_Quit(); - - if (app.renderer) { - SDL_DestroyRenderer(app.renderer); - app.renderer = nullptr; - } - if (app.window) { - SDL_DestroyWindow(app.window); - app.window = nullptr; - } - SDL_Quit(); -} - -} // namespace +#include "app/TetrisApp.h" int main(int, char **) { - AppRuntime app; - const int initRc = initApp(app); - if (initRc != 0) { - shutdownApp(app); - return initRc; - } - - runGameLoop(app); - shutdownApp(app); - return 0; + TetrisApp app; + return app.run(); } diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 895f434..d873037 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -1297,7 +1297,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi const SDL_Color textCol{200, 210, 230, 255}; const SDL_Color keyCol{255, 255, 255, 255}; - f->draw(renderer, x, y, "SDL3 TETRIS", 1.05f, keyCol); y += lineGap; + f->draw(renderer, x, y, "SDL3 SPACETRIS", 1.05f, keyCol); y += lineGap; f->draw(renderer, x, y, "C++20 / SDL3 / SDL3_ttf", 0.80f, textCol); y += lineGap + 6.0f; f->draw(renderer, x, y, "GAMEPLAY", 0.85f, SDL_Color{180,200,255,255}); y += lineGap;