#include "MenuState.h" #include "persistence/Scores.h" #include "../network/supabase_client.h" #include "graphics/Font.h" #include "../graphics/ui/HelpOverlay.h" #include "../core/GlobalState.h" #include "../core/Settings.h" #include "../core/state/StateManager.h" #include "../audio/Audio.h" #include "../audio/SoundEffect.h" #include #include #include #include #include #include #include #include // Use dynamic logical dimensions from GlobalState instead of hardcoded values // This allows the UI to adapt when the window is resized or goes fullscreen // Shared flags and resources are provided via StateContext `ctx`. // Removed fragile extern declarations and use `ctx.showLevelPopup`, `ctx.showSettingsPopup`, // `ctx.musicEnabled` and `ctx.hoveredButton` instead to avoid globals. // Menu helper wrappers are declared in a shared header implemented in main.cpp #include "../audio/MenuWrappers.h" #include "../utils/ImagePathResolver.h" #include "../graphics/renderers/UIRenderer.h" #include "../graphics/renderers/GameRenderer.h" #include "../ui/MenuLayout.h" #include "../ui/BottomMenu.h" #include // Frosted tint helper: draw a safe, inexpensive frosted overlay for the panel area. // This avoids renderer readback / surface APIs which aren't portable across SDL3 builds. static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP, float logicalScale, float panelTop, float panelH, SDL_Texture* sceneTex, int sceneW, int sceneH) { if (!renderer) return; // Preserve previous draw blend mode so callers don't get surprised when // the helper early-returns or changes blend state. SDL_BlendMode prevBlendMode = SDL_BLENDMODE_NONE; SDL_GetRenderDrawBlendMode(renderer, &prevBlendMode); // If we don't have a captured scene texture, fall back to the frosted tint. if (!sceneTex || sceneW <= 0 || sceneH <= 0) { float viewportLogicalW = (float)logicalVP.w / logicalScale; SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 200, 210, 220, 48); SDL_FRect base{ 0.0f, panelTop, viewportLogicalW, panelH }; SDL_RenderFillRect(renderer, &base); SDL_SetRenderDrawColor(renderer, 255, 255, 255, 24); SDL_FRect highlight{ 0.0f, panelTop, viewportLogicalW, std::max(2.0f, panelH * 0.06f) }; SDL_RenderFillRect(renderer, &highlight); SDL_SetRenderDrawColor(renderer, 16, 24, 32, 12); SDL_FRect shadow{ 0.0f, panelTop + panelH - std::max(2.0f, panelH * 0.06f), viewportLogicalW, std::max(2.0f, panelH * 0.06f) }; SDL_RenderFillRect(renderer, &shadow); // Restore previous blend mode SDL_SetRenderDrawBlendMode(renderer, prevBlendMode); return; } // Compute source rect in scene texture pixels for the panel area int panelWinX = 0; int panelWinY = static_cast(std::floor(panelTop * logicalScale + logicalVP.y)); int panelWinW = sceneW; // full width of captured scene int panelWinH = static_cast(std::ceil(panelH * logicalScale)); if (panelWinW <= 0 || panelWinH <= 0) return; // Downsample size (cheap Gaussian-ish blur via scale). int blurW = std::max(8, panelWinW / 6); int blurH = std::max(4, panelWinH / 6); // Create a small render target to draw the downsampled region into SDL_Texture* small = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, blurW, blurH); if (!small) { // Fall back to tint if we can't allocate float viewportLogicalW = (float)logicalVP.w / logicalScale; SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 200, 210, 220, 48); SDL_FRect base{ 0.0f, panelTop, viewportLogicalW, panelH }; SDL_RenderFillRect(renderer, &base); SDL_SetRenderDrawBlendMode(renderer, prevBlendMode); return; } // Source rectangle in the scene texture (pixel coords) as floats SDL_FRect srcRectF{ (float)panelWinX, (float)panelWinY, (float)panelWinW, (float)panelWinH }; // Render the sub-region of the scene into the small texture SDL_SetRenderTarget(renderer, small); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); SDL_RenderClear(renderer); SDL_FRect smallDst{ 0.0f, 0.0f, (float)blurW, (float)blurH }; SDL_RenderTexture(renderer, sceneTex, &srcRectF, &smallDst); // Reset target SDL_SetRenderTarget(renderer, nullptr); // Render the small texture scaled up to the panel area with linear filtering SDL_SetTextureBlendMode(small, SDL_BLENDMODE_BLEND); SDL_SetTextureScaleMode(small, SDL_SCALEMODE_LINEAR); float viewportLogicalW = (float)logicalVP.w / logicalScale; SDL_FRect dst{ 0.0f, panelTop, viewportLogicalW, panelH }; SDL_RenderTexture(renderer, small, nullptr, &dst); // Cleanup SDL_DestroyTexture(small); // Restore previous blend mode so caller drawing is unaffected SDL_SetRenderDrawBlendMode(renderer, prevBlendMode); } MenuState::MenuState(StateContext& ctx) : State(ctx) {} void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) { if (show) { if (!coopSetupVisible && !coopSetupAnimating) { // Avoid overlapping panels if (aboutPanelVisible && !aboutPanelAnimating) { aboutPanelAnimating = true; aboutDirection = -1; } if (helpPanelVisible && !helpPanelAnimating) { helpPanelAnimating = true; helpDirection = -1; } if (optionsVisible && !optionsAnimating) { optionsAnimating = true; optionsDirection = -1; } if (levelPanelVisible && !levelPanelAnimating) { levelPanelAnimating = true; levelDirection = -1; } if (exitPanelVisible && !exitPanelAnimating) { exitPanelAnimating = true; exitDirection = -1; if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; } coopSetupAnimating = true; coopSetupDirection = 1; coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0; coopSetupRectsValid = false; selectedButton = static_cast(ui::BottomMenuItem::Cooperate); // Ensure the transition value is non-zero so render code can show // the inline choice buttons immediately on the same frame. if (coopSetupTransition <= 0.0) coopSetupTransition = 0.001; } } else { if (coopSetupVisible && !coopSetupAnimating) { coopSetupAnimating = true; coopSetupDirection = -1; coopSetupRectsValid = false; // Resume menu music only when requested (ESC should pass resumeMusic=false) if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) { Audio::instance().playMenuMusic(); } } } } void MenuState::showHelpPanel(bool show) { if (show) { if (!helpPanelVisible && !helpPanelAnimating) { // Avoid overlapping panels if (aboutPanelVisible && !aboutPanelAnimating) { aboutPanelAnimating = true; aboutDirection = -1; } helpPanelAnimating = true; helpDirection = 1; helpScroll = 0.0; } } else { if (helpPanelVisible && !helpPanelAnimating) { helpPanelAnimating = true; helpDirection = -1; } } } void MenuState::showAboutPanel(bool show) { if (show) { if (!aboutPanelVisible && !aboutPanelAnimating) { // Avoid overlapping panels if (helpPanelVisible && !helpPanelAnimating) { helpPanelAnimating = true; helpDirection = -1; } if (optionsVisible && !optionsAnimating) { optionsAnimating = true; optionsDirection = -1; } if (levelPanelVisible && !levelPanelAnimating) { levelPanelAnimating = true; levelDirection = -1; } if (exitPanelVisible && !exitPanelAnimating) { exitPanelAnimating = true; exitDirection = -1; if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; } aboutPanelAnimating = true; aboutDirection = 1; } } else { if (aboutPanelVisible && !aboutPanelAnimating) { aboutPanelAnimating = true; aboutDirection = -1; } } } void MenuState::onEnter() { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState::onEnter called"); if (ctx.showExitConfirmPopup) { *ctx.showExitConfirmPopup = false; } if (ctx.exitPopupSelectedButton) { *ctx.exitPopupSelectedButton = 1; } // Refresh highscores for classic/cooperate/challenge asynchronously try { std::thread([this]() { try { auto c_classic = supabase::FetchHighscores("classic", 10); auto c_coop = supabase::FetchHighscores("cooperate", 10); auto c_challenge = supabase::FetchHighscores("challenge", 10); std::vector combined; combined.reserve(c_classic.size() + c_coop.size() + c_challenge.size()); combined.insert(combined.end(), c_classic.begin(), c_classic.end()); combined.insert(combined.end(), c_coop.begin(), c_coop.end()); combined.insert(combined.end(), c_challenge.begin(), c_challenge.end()); if (this->ctx.scores) this->ctx.scores->replaceAll(combined); } catch (...) { // swallow network errors - keep existing scores } }).detach(); } catch (...) {} } void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { const float LOGICAL_W = 1200.f; const float LOGICAL_H = 1000.f; // Use the same layout code as mouse hit-testing so each button is the same size. ui::MenuLayoutParams params{ static_cast(LOGICAL_W), static_cast(LOGICAL_H), logicalVP.w, logicalVP.h, logicalScale }; int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; const bool coopVsAI = ctx.coopVsAI ? *ctx.coopVsAI : false; ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel, coopVsAI); const int hovered = (ctx.hoveredButton ? *ctx.hoveredButton : -1); const double baseAlpha = 1.0; // Base alpha for button rendering // Pulse is encoded as a signed delta so PLAY can dim/brighten while focused. const double pulseDelta = (buttonPulseAlpha - 1.0); const double flashDelta = buttonFlash * buttonFlashAmount; ui::renderBottomMenu(renderer, ctx.pixelFont, menu, hovered, selectedButton, baseAlpha, pulseDelta + flashDelta); } void MenuState::onExit() { if (ctx.showExitConfirmPopup) { *ctx.showExitConfirmPopup = false; } // Clean up icon textures if (playIcon) { SDL_DestroyTexture(playIcon); playIcon = nullptr; } if (levelIcon) { SDL_DestroyTexture(levelIcon); levelIcon = nullptr; } if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; } if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; } if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; } if (coopInfoTexture) { SDL_DestroyTexture(coopInfoTexture); coopInfoTexture = nullptr; } } void MenuState::handleEvent(const SDL_Event& e) { // Coop setup panel navigation (modal within the menu) // Handle this FIRST and consume key events so the main menu navigation doesn't interfere. // Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat. if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) { switch (e.key.scancode) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_A: coopSetupSelected = 0; buttonFlash = 1.0; return; case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_D: coopSetupSelected = 1; buttonFlash = 1.0; return; // Do NOT allow up/down to change anything case SDL_SCANCODE_UP: case SDL_SCANCODE_DOWN: return; case SDL_SCANCODE_ESCAPE: showCoopSetupPanel(false, false); return; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: { const bool useAI = (coopSetupSelected == 1); if (ctx.coopVsAI) { *ctx.coopVsAI = useAI; } if (ctx.game) { ctx.game->setMode(GameMode::Cooperate); ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); } if (ctx.coopGame) { ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); } // Close the panel without restarting menu music; gameplay will take over. showCoopSetupPanel(false, false); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager); if (ctx.startPlayTransition) { ctx.startPlayTransition(); } else if (ctx.stateManager) { ctx.stateManager->setState(AppState::Playing); } return; } default: // Allow all other keys to be pressed, but don't let them affect the main menu while coop is open. return; } } // Mouse input for COOP setup panel or inline coop buttons if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) { if (coopSetupRectsValid) { // While the coop submenu is active (animating or visible) we disallow // mouse interaction — only keyboard LEFT/RIGHT/ESC is permitted. if (coopSetupAnimating || coopSetupVisible) { return; } const float mx = static_cast(e.button.x); const float my = static_cast(e.button.y); if (mx >= lastLogicalVP.x && my >= lastLogicalVP.y && mx <= (lastLogicalVP.x + lastLogicalVP.w) && my <= (lastLogicalVP.y + lastLogicalVP.h)) { const float lx = (mx - lastLogicalVP.x) / std::max(0.0001f, lastLogicalScale); const float ly = (my - lastLogicalVP.y) / std::max(0.0001f, lastLogicalScale); auto hit = [&](const SDL_FRect& r) { return lx >= r.x && lx <= (r.x + r.w) && ly >= r.y && ly <= (r.y + r.h); }; int chosen = -1; if (hit(coopSetupBtnRects[0])) chosen = 0; else if (hit(coopSetupBtnRects[1])) chosen = 1; if (chosen != -1) { coopSetupSelected = chosen; const bool useAI = (coopSetupSelected == 1); if (ctx.coopVsAI) { *ctx.coopVsAI = useAI; } if (ctx.game) { ctx.game->setMode(GameMode::Cooperate); ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); } if (ctx.coopGame) { ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); } showCoopSetupPanel(false); if (ctx.startPlayTransition) { ctx.startPlayTransition(); } else if (ctx.stateManager) { ctx.stateManager->setState(AppState::Playing); } return; } } } } // Keyboard navigation for menu buttons if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { // When the player uses the keyboard, don't let an old mouse hover keep focus on a button. if (ctx.hoveredButton) { *ctx.hoveredButton = -1; } auto triggerPlay = [&]() { if (ctx.startPlayTransition) { ctx.startPlayTransition(); } else if (ctx.stateManager) { ctx.stateManager->setState(AppState::Playing); } }; auto setExitSelection = [&](int value) { if (ctx.exitPopupSelectedButton) { *ctx.exitPopupSelectedButton = value; } }; auto getExitSelection = [&]() -> int { return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1; }; auto isExitPromptVisible = [&]() -> bool { return ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup; }; auto setExitPrompt = [&](bool visible) { if (ctx.showExitConfirmPopup) { *ctx.showExitConfirmPopup = visible; } }; // Inline exit HUD handling (replaces the old modal popup) if (exitPanelVisible && !exitPanelAnimating) { switch (e.key.scancode) { case SDL_SCANCODE_LEFT: // Move selection to YES exitSelectedButton = 0; if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; return; case SDL_SCANCODE_RIGHT: // Move selection to NO exitSelectedButton = 1; if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; return; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: if (exitSelectedButton == 0) { // Confirm quit if (ctx.requestQuit) { ctx.requestQuit(); } else { SDL_Event quit{}; quit.type = SDL_EVENT_QUIT; SDL_PushEvent(&quit); } } else { // Close HUD exitPanelAnimating = true; exitDirection = -1; if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; } return; case SDL_SCANCODE_ESCAPE: showCoopSetupPanel(false, true); // Cannot print std::function as a pointer; print presence (1/0) instead SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop ENTER pressed, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager); if (ctx.startPlayTransition) { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: calling startPlayTransition"); ctx.startPlayTransition(); } else { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: startPlayTransition is null"); } if (ctx.stateManager) { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: setting AppState::Playing on stateManager"); ctx.stateManager->setState(AppState::Playing); } else { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: stateManager is null"); } if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; return; case SDL_SCANCODE_PAGEDOWN: case SDL_SCANCODE_DOWN: { // scroll content down exitScroll += 40.0; return; } case SDL_SCANCODE_PAGEUP: case SDL_SCANCODE_UP: { exitScroll -= 40.0; if (exitScroll < 0.0) exitScroll = 0.0; return; } default: return; } } // If the inline options HUD is visible and not animating, capture navigation if (optionsVisible && !optionsAnimating) { // Options rows drawn here are 5 (Fullscreen, Music, Sound FX, Smooth Scroll, Return) constexpr int OPTIONS_ROW_COUNT = 5; switch (e.key.scancode) { case SDL_SCANCODE_UP: { optionsSelectedRow = (optionsSelectedRow + OPTIONS_ROW_COUNT - 1) % OPTIONS_ROW_COUNT; return; } case SDL_SCANCODE_DOWN: { optionsSelectedRow = (optionsSelectedRow + 1) % OPTIONS_ROW_COUNT; return; } case SDL_SCANCODE_LEFT: case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: { // Perform toggle/action for the selected option switch (optionsSelectedRow) { case 0: { // FULLSCREEN bool nextState = ! (ctx.fullscreenFlag ? *ctx.fullscreenFlag : Settings::instance().isFullscreen()); if (ctx.fullscreenFlag) *ctx.fullscreenFlag = nextState; if (ctx.applyFullscreen) ctx.applyFullscreen(nextState); Settings::instance().setFullscreen(nextState); Settings::instance().save(); return; } case 1: { // MUSIC bool next = !Settings::instance().isMusicEnabled(); Settings::instance().setMusicEnabled(next); Settings::instance().save(); if (ctx.musicEnabled) *ctx.musicEnabled = next; return; } case 2: { // SOUND FX bool next = !SoundEffectManager::instance().isEnabled(); SoundEffectManager::instance().setEnabled(next); Settings::instance().setSoundEnabled(next); Settings::instance().save(); return; } case 3: { // SMOOTH SCROLL bool next = !Settings::instance().isSmoothScrollEnabled(); Settings::instance().setSmoothScrollEnabled(next); Settings::instance().save(); return; } case 4: { // RETURN TO MENU -> hide panel optionsAnimating = true; optionsDirection = -1; return; } } } default: break; } } // If the inline help HUD is visible and not animating, capture navigation if (helpPanelVisible && !helpPanelAnimating) { switch (e.key.scancode) { case SDL_SCANCODE_ESCAPE: case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: // Close help panel helpPanelAnimating = true; helpDirection = -1; return; case SDL_SCANCODE_LEFT: case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_UP: case SDL_SCANCODE_DOWN: // Arrow keys: close help and immediately return to main menu navigation. helpPanelAnimating = true; helpDirection = -1; break; case SDL_SCANCODE_PAGEDOWN: helpScroll += 40.0; return; case SDL_SCANCODE_PAGEUP: helpScroll -= 40.0; if (helpScroll < 0.0) helpScroll = 0.0; return; default: return; } } // If the inline about HUD is visible and not animating, capture navigation if (aboutPanelVisible && !aboutPanelAnimating) { switch (e.key.scancode) { case SDL_SCANCODE_ESCAPE: case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: aboutPanelAnimating = true; aboutDirection = -1; return; case SDL_SCANCODE_LEFT: case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_UP: case SDL_SCANCODE_DOWN: aboutPanelAnimating = true; aboutDirection = -1; break; default: return; } } // If inline level HUD visible and not animating, capture navigation if (levelPanelVisible && !levelPanelAnimating) { // Start navigation from tentative hover if present, otherwise from committed selection int c = (levelHovered >= 0) ? levelHovered : (levelSelected < 0 ? 0 : levelSelected); switch (e.key.scancode) { case SDL_SCANCODE_LEFT: if (c % 4 > 0) c--; break; case SDL_SCANCODE_RIGHT: if (c % 4 < 3) c++; break; case SDL_SCANCODE_UP: if (c / 4 > 0) c -= 4; break; case SDL_SCANCODE_DOWN: if (c / 4 < 4) c += 4; break; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: // Confirm tentative selection levelSelected = c; if (ctx.startLevelSelection) *ctx.startLevelSelection = levelSelected; // close HUD levelPanelAnimating = true; levelDirection = -1; // clear hover candidate levelHovered = -1; return; case SDL_SCANCODE_ESCAPE: levelPanelAnimating = true; levelDirection = -1; levelHovered = -1; return; default: break; } // Move tentative cursor (don't commit to startLevelSelection yet) levelHovered = c; // Consume the event so main menu navigation does not also run return; } // Coop setup panel navigation (modal within the menu) if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0) { switch (e.key.scancode) { case SDL_SCANCODE_LEFT: coopSetupSelected = 0; buttonFlash = 1.0; return; case SDL_SCANCODE_RIGHT: coopSetupSelected = 1; buttonFlash = 1.0; return; // Explicitly consume Up/Down so main menu navigation doesn't trigger case SDL_SCANCODE_UP: case SDL_SCANCODE_DOWN: return; case SDL_SCANCODE_ESCAPE: // Close coop panel without restarting music showCoopSetupPanel(false, false); return; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: { const bool useAI = (coopSetupSelected == 1); if (ctx.coopVsAI) { *ctx.coopVsAI = useAI; } if (ctx.game) { ctx.game->setMode(GameMode::Cooperate); ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); } if (ctx.coopGame) { ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); } showCoopSetupPanel(false, false); triggerPlay(); return; } default: break; } } switch (e.key.scancode) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_UP: { const int total = MENU_BTN_COUNT; selectedButton = (selectedButton + total - 1) % total; // brief bright flash on navigation buttonFlash = 1.0; break; } case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_DOWN: { const int total = MENU_BTN_COUNT; selectedButton = (selectedButton + 1) % total; // brief bright flash on navigation buttonFlash = 1.0; break; } case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: if (!ctx.stateManager) { break; } switch (selectedButton) { case 0: // Endless play if (ctx.game) ctx.game->setMode(GameMode::Endless); triggerPlay(); break; case 1: // Cooperative play: open setup panel (2P vs AI) showCoopSetupPanel(true); break; case 2: // Start challenge run at level 1 if (ctx.game) { ctx.game->setMode(GameMode::Challenge); if (ctx.skipNextLevelUpJingle) { *ctx.skipNextLevelUpJingle = true; } ctx.game->startChallengeRun(1); } triggerPlay(); break; case 3: // Toggle inline level selector HUD (show/hide) if (!levelPanelVisible && !levelPanelAnimating) { levelPanelAnimating = true; levelDirection = 1; // show // initialize tentative cursor to current selected level levelHovered = levelSelected < 0 ? 0 : levelSelected; } else if (levelPanelVisible && !levelPanelAnimating) { levelPanelAnimating = true; levelDirection = -1; // hide } break; case 4: // Toggle the options panel with an animated slide-in/out. if (!optionsVisible && !optionsAnimating) { optionsAnimating = true; optionsDirection = 1; // show } else if (optionsVisible && !optionsAnimating) { optionsAnimating = true; optionsDirection = -1; // hide } break; case 5: // Toggle the inline HELP HUD (show/hide) if (!helpPanelVisible && !helpPanelAnimating) { helpPanelAnimating = true; helpDirection = 1; // show helpScroll = 0.0; } else if (helpPanelVisible && !helpPanelAnimating) { helpPanelAnimating = true; helpDirection = -1; // hide } break; case 6: // Toggle the inline ABOUT HUD (show/hide) if (!aboutPanelVisible && !aboutPanelAnimating) { aboutPanelAnimating = true; aboutDirection = 1; } else if (aboutPanelVisible && !aboutPanelAnimating) { aboutPanelAnimating = true; aboutDirection = -1; } break; case 7: // Show the inline exit HUD if (!exitPanelVisible && !exitPanelAnimating) { exitPanelAnimating = true; exitDirection = 1; exitSelectedButton = 1; if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = true; if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; } break; } break; case SDL_SCANCODE_ESCAPE: if (coopSetupVisible && !coopSetupAnimating) { showCoopSetupPanel(false, false); return; } // If options panel is visible, hide it first. if (optionsVisible && !optionsAnimating) { optionsAnimating = true; optionsDirection = -1; return; } // Show inline exit HUD on ESC if (!exitPanelVisible && !exitPanelAnimating) { exitPanelAnimating = true; exitDirection = 1; exitSelectedButton = 1; if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = true; if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; } break; default: break; } } } void MenuState::update(double frameMs) { // Update logo animation counter GlobalState::instance().logoAnimCounter += frameMs; // Advance options panel animation if active if (optionsAnimating) { double delta = (frameMs / optionsTransitionDurationMs) * static_cast(optionsDirection); optionsTransition += delta; if (optionsTransition >= 1.0) { optionsTransition = 1.0; optionsVisible = true; optionsAnimating = false; } else if (optionsTransition <= 0.0) { optionsTransition = 0.0; optionsVisible = false; optionsAnimating = false; } } // Advance level panel animation if active if (levelPanelAnimating) { double delta = (frameMs / levelTransitionDurationMs) * static_cast(levelDirection); levelTransition += delta; if (levelTransition >= 1.0) { levelTransition = 1.0; levelPanelVisible = true; levelPanelAnimating = false; } else if (levelTransition <= 0.0) { levelTransition = 0.0; levelPanelVisible = false; levelPanelAnimating = false; } } // Advance exit panel animation if active if (exitPanelAnimating) { double delta = (frameMs / exitTransitionDurationMs) * static_cast(exitDirection); exitTransition += delta; if (exitTransition >= 1.0) { exitTransition = 1.0; exitPanelVisible = true; exitPanelAnimating = false; } else if (exitTransition <= 0.0) { exitTransition = 0.0; exitPanelVisible = false; exitPanelAnimating = false; } } // Advance help panel animation if active if (helpPanelAnimating) { double delta = (frameMs / helpTransitionDurationMs) * static_cast(helpDirection); helpTransition += delta; if (helpTransition >= 1.0) { helpTransition = 1.0; helpPanelVisible = true; helpPanelAnimating = false; } else if (helpTransition <= 0.0) { helpTransition = 0.0; helpPanelVisible = false; helpPanelAnimating = false; } } // Advance about panel animation if active if (aboutPanelAnimating) { double delta = (frameMs / aboutTransitionDurationMs) * static_cast(aboutDirection); aboutTransition += delta; if (aboutTransition >= 1.0) { aboutTransition = 1.0; aboutPanelVisible = true; aboutPanelAnimating = false; } else if (aboutTransition <= 0.0) { aboutTransition = 0.0; aboutPanelVisible = false; aboutPanelAnimating = false; } } // Advance coop setup panel animation if active if (coopSetupAnimating) { double delta = (frameMs / coopSetupTransitionDurationMs) * static_cast(coopSetupDirection); coopSetupTransition += delta; if (coopSetupTransition >= 1.0) { coopSetupTransition = 1.0; coopSetupVisible = true; coopSetupAnimating = false; } else if (coopSetupTransition <= 0.0) { coopSetupTransition = 0.0; coopSetupVisible = false; coopSetupAnimating = false; } } // Animate level selection highlight position toward the selected cell center if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) { // Recompute same grid geometry used in render to find target center const float LOGICAL_W = 1200.f; const float LOGICAL_H = 1000.f; float winW = (float)lastLogicalVP.w; float winH = (float)lastLogicalVP.h; float contentOffsetX = 0.0f; float contentOffsetY = 0.0f; UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W, LOGICAL_H, lastLogicalScale, contentOffsetX, contentOffsetY); const float PW = std::min(520.0f, LOGICAL_W * 0.65f); const float PH = std::min(360.0f, LOGICAL_H * 0.7f); float panelBaseX = (LOGICAL_W - PW) * 0.5f + contentOffsetX; float panelBaseY = (LOGICAL_H - PH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); float marginX = 34.0f, marginY = 56.0f; SDL_FRect area{ panelBaseX + marginX, panelBaseY + marginY, PW - 2.0f * marginX, PH - marginY - 28.0f }; const int cols = 4, rows = 5; const float gapX = 12.0f, gapY = 12.0f; float cellW = (area.w - (cols - 1) * gapX) / cols; float cellH = (area.h - (rows - 1) * gapY) / rows; int targetIdx = std::clamp((levelHovered >= 0 ? levelHovered : levelSelected), 0, 19); int tr = targetIdx / cols, tc = targetIdx % cols; double targetX = area.x + tc * (cellW + gapX) + cellW * 0.5f; double targetY = area.y + tr * (cellH + gapY) + cellH * 0.5f; if (!levelHighlightInitialized) { levelHighlightX = targetX; levelHighlightY = targetY; levelHighlightInitialized = true; } else { // Exponential smoothing: alpha = 1 - exp(-k * dt) double k = levelHighlightSpeed; // user-tunable speed constant double alpha = 1.0 - std::exp(-k * frameMs); if (alpha < 1e-6) alpha = std::min(1.0, frameMs * 0.02); levelHighlightX += (targetX - levelHighlightX) * alpha; levelHighlightY += (targetY - levelHighlightY) * alpha; } } // Update pulsing animation (used for PLAY emphasis) if (buttonPulseEnabled) { buttonPulseTime += frameMs; double t = (buttonPulseTime * 0.001) * buttonPulseSpeed; // seconds * speed double s = 0.0; switch (buttonPulseEasing) { case 0: // sin s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5; break; case 1: // triangle { double ft = t - std::floor(t); s = (ft < 0.5) ? (ft * 2.0) : (2.0 - ft * 2.0); break; } case 2: // exponential ease-in-out (normalized) { double ft = t - std::floor(t); if (ft < 0.5) { s = 0.5 * std::pow(2.0, 20.0 * ft - 10.0); } else { s = 1.0 - 0.5 * std::pow(2.0, -20.0 * ft + 10.0); } // Clamp to 0..1 in case of numeric issues if (s < 0.0) s = 0.0; if (s > 1.0) s = 1.0; break; } default: s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5; } buttonPulseAlpha = buttonPulseMinAlpha + s * (buttonPulseMaxAlpha - buttonPulseMinAlpha); } else { buttonPulseAlpha = 1.0; } // Keep the base group alpha stable; pulsing is applied selectively in the renderer. buttonGroupAlpha = 1.0; // Update flash decay if (buttonFlash > 0.0) { buttonFlash -= frameMs * buttonFlashDecay; if (buttonFlash < 0.0) buttonFlash = 0.0; } } void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { // Use fixed logical dimensions to match main.cpp and ensure consistent layout // This prevents the UI from floating apart on wide/tall screens const float LOGICAL_W = 1200.f; const float LOGICAL_H = 1000.f; // Trace entry to persistent log for debugging abrupt exit/crash during render { FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render entry\n"); fclose(f); } } // Compute content offsets (same approach as main.cpp for proper centering) float winW = (float)logicalVP.w; float winH = (float)logicalVP.h; float contentOffsetX = 0.0f; float contentOffsetY = 0.0f; UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W, LOGICAL_H, logicalScale, contentOffsetX, contentOffsetY); // Cache logical viewport/scale for update() so it can compute HUD target positions lastLogicalScale = logicalScale; lastLogicalVP = logicalVP; // Background is drawn by main (stretched to the full window) to avoid double-draw. { FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render ctx.mainScreenTex=%llu (w=%d h=%d)\n", (unsigned long long)(uintptr_t)ctx.mainScreenTex, ctx.mainScreenW, ctx.mainScreenH); fclose(f); } } FontAtlas* useFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; // Slide-space amount for the options HUD (how much highscores move) const float moveAmount = 420.0f; // increased so lower score rows slide further up // Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown. // Exclude coopSetupTransition from the highscores slide so opening the // COOPERATE setup does not shift the highscores panel upward. float combinedTransition = static_cast(std::max( std::max(std::max(optionsTransition, levelTransition), exitTransition), std::max(helpTransition, aboutTransition) )); float eased = combinedTransition * combinedTransition * (3.0f - 2.0f * combinedTransition); // cubic smoothstep float panelDelta = eased * moveAmount; // Draw a larger centered logo above the highscores area, then a small "TOP PLAYER" label // Move the whole block slightly up to better match the main screen overlay framing. float menuYOffset = LOGICAL_H * 0.03f; // same offset used for buttons float scoresYOffset = -LOGICAL_H * 0.05f; // Move logo and highscores upward by ~10% of logical height for better vertical balance float upwardShift = LOGICAL_H * 0.08f; float topPlayersY = LOGICAL_H * 0.20f + contentOffsetY - panelDelta + menuYOffset + scoresYOffset - upwardShift; float scoresStartY = topPlayersY; if (useFont) { // Preferred logo texture (full) if present, otherwise the small logo SDL_Texture* logoTex = ctx.logoTex ? ctx.logoTex : ctx.logoSmallTex; float logoDrawH = 72.0f; // larger logo height if (logoTex) { float texW = 0.0f, texH = 0.0f; SDL_GetTextureSize(logoTex, &texW, &texH); if (texW > 0.0f && texH > 0.0f) { float scale = logoDrawH / texH; float drawW = texW * scale; SDL_FRect dst{ (LOGICAL_W - drawW) * 0.5f + contentOffsetX, topPlayersY, drawW, logoDrawH }; SDL_SetTextureAlphaMod(logoTex, 230); SDL_RenderTexture(renderer, logoTex, nullptr, &dst); // move down for title under logo scoresStartY = dst.y + dst.h + 8.0f; } } // Small label under the logo — show "COOPERATE" when coop setup is active const std::string smallTitle = (coopSetupAnimating || coopSetupVisible) ? "COOPERATE" : "TOP PLAYER"; float titleScale = 0.9f; int tW = 0, tH = 0; useFont->measure(smallTitle, titleScale, tW, tH); float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX; useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255}); scoresStartY += (float)tH + 12.0f; } static const std::vector EMPTY_SCORES; const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES; // Choose which game_type to show based on current menu selection std::string wantedType = "classic"; if (selectedButton == 0) wantedType = "classic"; // Play / Endless else if (selectedButton == 1) wantedType = "cooperate"; // Coop else if (selectedButton == 2) wantedType = "challenge"; // Challenge // Filter highscores to the desired game type std::vector filtered; filtered.reserve(hs.size()); for (const auto &e : hs) { if (e.gameType == wantedType) filtered.push_back(e); } size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10 // Draw highscores as an inline HUD-like panel (no opaque box), matching Options/Level/Exit style // Keep highscores visible while the coop setup is animating; hide them only // once the coop setup is fully visible so the buttons can appear afterward. if (useFont && !coopSetupVisible) { const float panelW = (wantedType == "cooperate") ? std::min(920.0f, LOGICAL_W * 0.92f) : std::min(780.0f, LOGICAL_W * 0.85f); const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows // Shift the entire highscores panel slightly left (~1.5% of logical width) float panelShift = LOGICAL_W * 0.015f; float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX - panelShift; float panelBaseY = scoresStartY - 20.0f - panelDelta; // nudge up and apply HUD slide // Column positions inside panel float colLeft = panelBaseX + 12.0f; float colWidth = panelW - 24.0f; // Center the column group inside the panel and place columns relative to center float centerX = panelBaseX + panelW * 0.5f; // Tighter column spacing: compress multipliers around center float rankX = centerX - colWidth * 0.34f; // Move PLAYER column a bit further left while leaving others unchanged float nameX = (wantedType == "cooperate") ? centerX - colWidth * 0.30f : centerX - colWidth * 0.25f; // Move SCORE column slightly left for tighter layout (adjusted for coop) float scoreX = (wantedType == "cooperate") ? centerX - colWidth * 0.02f : centerX - colWidth * 0.06f; float linesX = centerX + colWidth * 0.14f; float levelX = centerX + colWidth * 0.26f; float timeX = centerX + colWidth * 0.38f; // Column header labels float headerY = panelBaseY + 26.0f; // Slightly smaller header for compactness float headerScale = 0.75f; // Use same color as Options heading (use full alpha for maximum brightness) SDL_Color headerColor = SDL_Color{120,220,255,255}; useFont->draw(renderer, rankX, headerY, "#", headerScale, headerColor); useFont->draw(renderer, nameX, headerY, (wantedType == "cooperate") ? "PLAYERS" : "PLAYER", headerScale, headerColor); useFont->draw(renderer, scoreX, headerY, "SCORE", headerScale, headerColor); useFont->draw(renderer, linesX, headerY, "LINES", headerScale, headerColor); useFont->draw(renderer, levelX, headerY, "LVL", headerScale, headerColor); useFont->draw(renderer, timeX, headerY, "TIME", headerScale, headerColor); const float rowHeight = 28.0f; float rowY = panelBaseY + 28.0f; float rowScale = 0.80f; for (size_t i = 0; i < maxDisplay; ++i) { float y = rowY + i * rowHeight; // (removed thin blue separator between rows per request) // Subtle highlight wave for the list similar to before float wave = std::sin((float)GlobalState::instance().logoAnimCounter * 0.006f + i * 0.25f) * 4.0f; // Per-row entrance staggering and easing to make movement fancier const float baseEntrance = 40.0f; // pixels rows slide from const float perRowDelay = 0.06f; // stagger delay per row (in eased 0..1 space) float rowDelay = perRowDelay * (float)i; float rowT = 0.0f; if (eased > rowDelay) rowT = (eased - rowDelay) / (1.0f - rowDelay); if (rowT < 0.0f) rowT = 0.0f; if (rowT > 1.0f) rowT = 1.0f; // cubic smoothstep per row float rowEase = rowT * rowT * (3.0f - 2.0f * rowT); float entryOffset = (1.0f - rowEase) * baseEntrance; // Slight alpha fade during entrance float alphaMul = 1; // Slight scale slip per row (keeps earlier visual taper) float curRowScale = rowScale - std::min(0.20f, 0.05f * (float)i); // Base row color matches header brightness; top 3 get vivid medal colors SDL_Color baseRowColor = SDL_Color{ headerColor.r, headerColor.g, headerColor.b, 255 }; if (i == 0) { baseRowColor = SDL_Color{255,215,0,255}; // bright gold } else if (i == 1) { baseRowColor = SDL_Color{230,230,235,255}; // bright silver } else if (i == 2) { baseRowColor = SDL_Color{255,165,80,255}; // brighter bronze/orange } SDL_Color rowColor = baseRowColor; // Use entrance alpha to fade in but keep RGB full-brightness; map alphaMul to 0..1 rowColor.a = static_cast(std::round(255.0f * alphaMul)); // horizontal subtle slide for name column to add a little polish float nameXAdj = nameX - (1.0f - rowEase) * 8.0f; char rankStr[8]; std::snprintf(rankStr, sizeof(rankStr), "%zu.", i + 1); useFont->draw(renderer, rankX, y + wave + entryOffset, rankStr, curRowScale, rowColor); useFont->draw(renderer, nameXAdj, y + wave + entryOffset, filtered[i].name, curRowScale, rowColor); char scoreStr[16]; std::snprintf(scoreStr, sizeof(scoreStr), "%d", filtered[i].score); useFont->draw(renderer, scoreX, y + wave + entryOffset, scoreStr, curRowScale, rowColor); char linesStr[8]; std::snprintf(linesStr, sizeof(linesStr), "%d", filtered[i].lines); useFont->draw(renderer, linesX, y + wave + entryOffset, linesStr, curRowScale, rowColor); char levelStr[8]; std::snprintf(levelStr, sizeof(levelStr), "%d", filtered[i].level); useFont->draw(renderer, levelX, y + wave + entryOffset, levelStr, curRowScale, rowColor); char timeStr[16]; int mins = int(filtered[i].timeSec) / 60; int secs = int(filtered[i].timeSec) % 60; std::snprintf(timeStr, sizeof(timeStr), "%d:%02d", mins, secs); useFont->draw(renderer, timeX, y + wave + entryOffset, timeStr, curRowScale, rowColor); } } // The main_screen overlay is drawn by main.cpp as the background // We don't need to draw it again here as a logo // Draw bottom action buttons with responsive sizing (reduced to match main mouse hit-test) // Use the contentW calculated at the top with content offsets float contentW = LOGICAL_W * logicalScale; bool isSmall = (contentW < 700.0f); // Adjust button dimensions to match the background button graphics float btnW = 200.0f; // Fixed width to match background buttons float btnH = 70.0f; // Fixed height to match background buttons float btnX = LOGICAL_W * 0.5f + contentOffsetX; // Adjust vertical position to align with background buttons; move slightly down float btnY = LOGICAL_H * 0.865f + contentOffsetY + (LOGICAL_H * 0.02f) + menuYOffset; if (ctx.pixelFont) { { FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render drawing buttons; pixelFont=%llu\n", (unsigned long long)(uintptr_t)ctx.pixelFont); fclose(f); } } char levelBtnText[32]; int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); struct MenuButtonDef { SDL_Color bg; SDL_Color border; std::string label; }; std::array buttons = { MenuButtonDef{ SDL_Color{60,180,80,255}, SDL_Color{30,120,40,255}, "PLAY" }, MenuButtonDef{ SDL_Color{40,140,240,255}, SDL_Color{20,100,200,255}, levelBtnText }, MenuButtonDef{ SDL_Color{130,80,210,255}, SDL_Color{90,40,170,255}, "OPTIONS" }, MenuButtonDef{ SDL_Color{200,200,60,255}, SDL_Color{150,150,40,255}, "HELP" }, MenuButtonDef{ SDL_Color{200,70,70,255}, SDL_Color{150,40,40,255}, "EXIT" } }; // Icon array (nullptr if icon not loaded) std::array icons = { playIcon, levelIcon, optionsIcon, helpIcon, exitIcon }; // Fixed spacing to match background button positions float spacing = isSmall ? btnW * 1.2f : btnW * 1.15f; // Draw each button individually so each can have its own coordinates if (drawMainButtonNormally) { // Draw semi-transparent background panel behind the full button group { float groupCenterX = btnX; float halfSpan = 2.0f * spacing; float panelLeft = groupCenterX - halfSpan - btnW * 0.5f - 14.0f; float panelRight = groupCenterX + halfSpan + btnW * 0.5f + 14.0f; float panelTop = btnY - btnH * 0.5f - 12.0f; float panelH = btnH + 24.0f; SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); // Backdrop blur pass before tint (use captured scene texture if available) renderBackdropBlur(renderer, logicalVP, logicalScale, panelTop, panelH, ctx.sceneTex, ctx.sceneW, ctx.sceneH); // Brighter, less-opaque background to increase contrast (match top path) SDL_SetRenderDrawColor(renderer, 28, 36, 46, 180); // Fill full-width background so edges are covered in fullscreen float viewportLogicalW = (float)logicalVP.w / logicalScale; SDL_FRect fullPanel{ 0.0f, panelTop, viewportLogicalW, panelH }; SDL_RenderFillRect(renderer, &fullPanel); // Also draw the central strip to keep visual center emphasis SDL_FRect panelRect{ panelLeft, panelTop, panelRight - panelLeft, panelH }; SDL_RenderFillRect(renderer, &panelRect); // subtle border across full logical width SDL_SetRenderDrawColor(renderer, 120, 140, 160, 200); // Expand border to cover full window width (use actual viewport) SDL_FRect borderFull{ 0.0f, panelTop, viewportLogicalW, panelH }; SDL_RenderRect(renderer, &borderFull); } // Button 0 - PLAY { const int i = 0; float cxCenter = 0.0f; float cyCenter = btnY; if (ctx.menuButtonsExplicit) { cxCenter = ctx.menuButtonCX[i] + contentOffsetX; cyCenter = ctx.menuButtonCY[i] + contentOffsetY; } else { float offset = (static_cast(i) - 1.5f) * spacing; cxCenter = btnX + offset + 15.0f; } UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, buttons[i].label, false, selectedButton == i, buttons[i].bg, buttons[i].border, true, icons[i]); // no per-button neon outline; group background handles emphasis } // Button 1 - LEVEL { const int i = 1; float cxCenter = 0.0f; float cyCenter = btnY; if (ctx.menuButtonsExplicit) { cxCenter = ctx.menuButtonCX[i] + contentOffsetX; cyCenter = ctx.menuButtonCY[i] + contentOffsetY; } else { float offset = (static_cast(i) - 1.5f) * spacing; cxCenter = btnX + offset; } UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, buttons[i].label, false, selectedButton == i, buttons[i].bg, buttons[i].border, true, icons[i]); } // Button 2 - OPTIONS { const int i = 2; float cxCenter = 0.0f; float cyCenter = btnY; if (ctx.menuButtonsExplicit) { cxCenter = ctx.menuButtonCX[i] + contentOffsetX; cyCenter = ctx.menuButtonCY[i] + contentOffsetY; } else { float offset = (static_cast(i) - 1.5f) * spacing; cxCenter = btnX + offset - 24.0f; } UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, buttons[i].label, false, selectedButton == i, buttons[i].bg, buttons[i].border, true, icons[i]); } // Button 3 - EXIT { const int i = 3; float cxCenter = 0.0f; float cyCenter = btnY; if (ctx.menuButtonsExplicit) { cxCenter = ctx.menuButtonCX[i] + contentOffsetX; cyCenter = ctx.menuButtonCY[i] + contentOffsetY; } else { float offset = (static_cast(i) - 2.0f) * spacing; cxCenter = btnX + offset; } UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, buttons[i].label, false, selectedButton == i, buttons[i].bg, buttons[i].border, true, icons[i]); // Button 4 - EXIT { const int i = 4; float cxCenter = 0.0f; float cyCenter = btnY; if (ctx.menuButtonsExplicit) { cxCenter = ctx.menuButtonCX[i] + contentOffsetX; cyCenter = ctx.menuButtonCY[i] + contentOffsetY; } else { float offset = (static_cast(i) - 2.0f) * spacing; cxCenter = btnX + offset - 24.0f; } UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, buttons[i].label, false, selectedButton == i, buttons[i].bg, buttons[i].border, true, icons[i]); } } } } // Inline COOP choice buttons: when COOPERATE is selected show two large // choice buttons in the highscores panel area (top of the screen). // coopSetupRectsValid is cleared each frame and set to true when buttons are drawn coopSetupRectsValid = false; // Draw the inline COOP choice buttons as soon as the coop setup starts // animating or is visible. Highscores are no longer slid upward when // the setup opens, so the buttons can show immediately. if (coopSetupAnimating || coopSetupVisible) { // Recompute panel geometry matching highscores layout above so buttons // appear centered inside the same visual area. const float panelW = std::min(920.0f, LOGICAL_W * 0.92f); const float panelShift = LOGICAL_W * 0.015f; const float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX - panelShift; const float panelH = 36.0f + maxDisplay * 36.0f; // same as highscores panel // Highscores are animated upward by `panelDelta` while opening the coop setup. // We want the choice buttons to appear *after* that scroll, in the original // highscores area (not sliding offscreen with the scores). const float panelBaseY = scoresStartY - 20.0f; // Make the choice buttons smaller, add more spacing, and raise them higher const float btnW2 = std::min(300.0f, panelW * 0.30f); const float btnH2 = 60.0f; const float gap = 96.0f; // Shift the image and buttons to the right for layout balance (reduced) const float shiftX = 20.0f; // move right by 30px (moved 20px left from previous) const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f + shiftX; // Move the buttons up by ~80px to sit closer under the logo const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f; coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 }; coopSetupBtnRects[1] = SDL_FRect{ bx + btnW2 + gap, by, btnW2, btnH2 }; coopSetupRectsValid = true; SDL_Color bg{ 24, 36, 52, 220 }; SDL_Color border{ 110, 200, 255, 220 }; // Load coop info image once when the coop setup is first shown if (!coopInfoTexture) { const std::string resolved = AssetPath::resolveImagePath("assets/images/cooperate_info.png"); if (!resolved.empty()) { SDL_Surface* surf = IMG_Load(resolved.c_str()); if (surf) { // Save dimensions from surface then create texture coopInfoTexW = surf->w; coopInfoTexH = surf->h; coopInfoTexture = SDL_CreateTextureFromSurface(renderer, surf); SDL_DestroySurface(surf); } else { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "MenuState: failed to load %s: %s", resolved.c_str(), SDL_GetError()); } } } // If the image loaded, render it centered above the two choice buttons // Compute fade alpha from the coop transition so it can be used for image, text and buttons float alphaFactor = static_cast(coopSetupTransition); if (alphaFactor < 0.0f) alphaFactor = 0.0f; if (alphaFactor > 1.0f) alphaFactor = 1.0f; if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) { float totalW = btnW2 * 2.0f + gap; // Increase allowed image width by ~15% (was 0.75 of totalW) const float scaleFactor = 0.75f * 1.25f; // ~0.8625 float maxImgW = totalW * scaleFactor; float targetW = std::min(maxImgW, static_cast(coopInfoTexW)); float scale = targetW / static_cast(coopInfoTexW); float targetH = static_cast(coopInfoTexH) * scale; float imgX = bx + (totalW - targetW) * 0.5f; float imgY = by - targetH - 8.0f; // keep the small gap above buttons float minY = panelBaseY + 6.0f; if (imgY < minY) imgY = minY; SDL_FRect dst{ imgX, imgY, targetW, targetH }; SDL_SetTextureBlendMode(coopInfoTexture, SDL_BLENDMODE_BLEND); // Make the coop info image slightly transparent scaled by transition SDL_SetTextureAlphaMod(coopInfoTexture, static_cast(std::round(200.0f * alphaFactor))); SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst); // Draw cooperative instructions inside the panel area (overlayed on the panel background) FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; if (f) { const float pad = 38.0f; float textX = panelBaseX + pad; // Position the text over the lower portion of the image (overlay) // Move the block upward by ~150px to match UI request float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f; // Bulleted list (measure sample line height first) const std::vector bullets = { "The playfield is shared between two players", "Each player controls one half of the grid", "A line clears only when both halves are filled", "Timing and coordination are essential" }; float bulletScale = 0.78f; SDL_Color bulletCol{200,220,230,220}; bulletCol.a = static_cast(std::round(bulletCol.a * alphaFactor)); int sampleLW = 0, sampleLH = 0; f->measure(bullets[0], bulletScale, sampleLW, sampleLH); // Header: move it up by one sample row so it sits higher const std::string header = "* HOW TO PLAY – COOPERATE MODE *"; float headerScale = 0.95f; int hW=0, hH=0; f->measure(header, headerScale, hW, hH); float hx = panelBaseX + (panelW - static_cast(hW)) * 0.5f + 40.0f; // nudge header right by 40px float headerY = textY - static_cast(sampleLH); SDL_Color headerCol = SDL_Color{220,240,255,230}; headerCol.a = static_cast(std::round(headerCol.a * alphaFactor)); f->draw(renderer, hx, headerY, header, headerScale, headerCol); // Start body text slightly below header textY = headerY + static_cast(hH) + 8.0f; // Shift non-header text to the right by 100px and down by 20px float bulletX = textX + 200.0f; textY += 20.0f; for (const auto &line : bullets) { std::string withBullet = std::string("• ") + line; f->draw(renderer, bulletX, textY, withBullet, bulletScale, bulletCol); int lw=0, lH=0; f->measure(withBullet, bulletScale, lw, lH); textY += static_cast(lH) + 6.0f; } // GOAL section (aligned with shifted bullets) textY += 6.0f; std::string goalTitle = "GOAL:"; SDL_Color goalTitleCol = SDL_Color{255,215,80,230}; goalTitleCol.a = static_cast(std::round(goalTitleCol.a * alphaFactor)); f->draw(renderer, bulletX, textY, goalTitle, 0.88f, goalTitleCol); int gW=0, gH=0; f->measure(goalTitle, 0.88f, gW, gH); float goalX = bulletX + static_cast(gW) + 10.0f; std::string goalText = "Clear lines together and achieve the highest TEAM SCORE"; SDL_Color goalTextCol = SDL_Color{220,240,255,220}; goalTextCol.a = static_cast(std::round(goalTextCol.a * alphaFactor)); f->draw(renderer, goalX, textY, goalText, 0.86f, goalTextCol); } } // Delay + eased fade specifically for the two coop buttons so they appear after the image/text. const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading float rawBtn = (alphaFactor - btnDelay) / (1.0f - btnDelay); rawBtn = std::clamp(rawBtn, 0.0f, 1.0f); // ease-in (squared) for a slower, smoother fade float buttonFade = rawBtn * rawBtn; SDL_Color bgA = bg; bgA.a = static_cast(std::round(bgA.a * buttonFade)); SDL_Color borderA = border; borderA.a = static_cast(std::round(borderA.a * buttonFade)); UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[0].x + btnW2 * 0.5f, coopSetupBtnRects[0].y + btnH2 * 0.5f, btnW2, btnH2, "2 PLAYERS", false, coopSetupSelected == 0, bgA, borderA, false, nullptr); UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[1].x + btnW2 * 0.5f, coopSetupBtnRects[1].y + btnH2 * 0.5f, btnW2, btnH2, "COMPUTER (AI)", false, coopSetupSelected == 1, bgA, borderA, false, nullptr); } // NOTE: slide-up COOP panel intentionally removed. Only the inline // highscores-area choice buttons are shown when coop setup is active. // Inline exit HUD (no opaque background) - slides into the highscores area if (exitTransition > 0.0) { float easedE = static_cast(exitTransition); easedE = easedE * easedE * (3.0f - 2.0f * easedE); const float panelW = 520.0f; const float panelH = 360.0f; float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX; float panelBaseY = (LOGICAL_H - panelH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); float slideAmount = LOGICAL_H * 0.42f; float panelY = panelBaseY + (1.0f - easedE) * slideAmount; FontAtlas* titleFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; if (titleFont) titleFont->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "EXIT GAME?", 1.6f, SDL_Color{255,200,80,255}); SDL_FRect area{ panelBaseX + 12.0f, panelY + 56.0f, panelW - 24.0f, panelH - 120.0f }; // Sample long message (scrollable) // Paragraph-style lines for a nicer exit confirmation message std::vector lines = { "Quit now to return to your desktop. Your current session will end.", "Press YES to quit immediately, or NO to return to the menu and continue playing.", "Adjust audio, controls and other settings anytime from the Options menu.", "Thanks for playing SPACETRIS — we hope to see you again!" }; // Draw scrollable text (no background box; increased line spacing) FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; float y = area.y - (float)exitScroll; const float lineSpacing = 34.0f; // increased spacing for readability if (f) { for (size_t i = 0; i < lines.size(); ++i) { f->draw(renderer, area.x + 6.0f, y + i * lineSpacing, lines[i], 1.0f, SDL_Color{200,220,240,255}); } } // Draw buttons at bottom of panel float btnW2 = 160.0f, btnH2 = 48.0f; float bx = panelBaseX + (panelW - (btnW2 * 2.0f + 12.0f)) * 0.5f; float by = panelY + panelH - 56.0f; // YES button SDL_Color yesBg{220,80,60, 200}; SDL_Color yesBorder{160,40,40,200}; SDL_Color noBg{60,140,200,200}; SDL_Color noBorder{30,90,160,200}; // Apply pulse alpha to buttons double aMul = std::clamp(buttonGroupAlpha + buttonFlash * buttonFlashAmount, 0.0, 1.0); yesBg.a = static_cast(std::round(aMul * static_cast(yesBg.a))); yesBorder.a = static_cast(std::round(aMul * static_cast(yesBorder.a))); noBg.a = static_cast(std::round(aMul * static_cast(noBg.a))); noBorder.a = static_cast(std::round(aMul * static_cast(noBorder.a))); UIRenderer::drawButton(renderer, ctx.pixelFont, bx + btnW2*0.5f, by, btnW2, btnH2, "YES", false, exitSelectedButton == 0, yesBg, yesBorder, true, nullptr); UIRenderer::drawButton(renderer, ctx.pixelFont, bx + btnW2*1.5f + 12.0f, by, btnW2, btnH2, "NO", false, exitSelectedButton == 1, noBg, noBorder, true, nullptr); // Ensure ctx mirrors selection for any other code if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; } // Popups (settings only - level popup is now a separate state) if (ctx.showSettingsPopup && *ctx.showSettingsPopup) { bool musicOn = ctx.musicEnabled ? *ctx.musicEnabled : true; bool soundOn = SoundEffectManager::instance().isEnabled(); UIRenderer::drawSettingsPopup(renderer, ctx.font, LOGICAL_W, LOGICAL_H, musicOn, soundOn); } // Draw animated options panel if in use (either animating or visible) if (optionsTransition > 0.0) { // HUD-style overlay: no opaque background; draw labels/values directly with separators const float panelW = 520.0f; const float panelH = 420.0f; float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX; // Move the HUD higher by ~10% of logical height so it sits above center float panelBaseY = (LOGICAL_H - panelH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); float panelY = panelBaseY + (1.0f - eased) * moveAmount; // For options/settings we prefer the secondary (Exo2) font for longer descriptions. FontAtlas* retroFont = ctx.font ? ctx.font : ctx.pixelFont; if (retroFont) { retroFont->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "OPTIONS", 1.8f, SDL_Color{120, 220, 255, 255}); } SDL_FRect area{panelBaseX, panelY + 48.0f, panelW, panelH - 64.0f}; constexpr int rowCount = 5; const float rowHeight = 64.0f; const float spacing = 8.0f; auto drawField = [&](int idx, float y, const std::string& label, const std::string& value) { SDL_FRect row{area.x, y, area.w, rowHeight}; // Draw thin separator (1px high filled rect) so we avoid platform-specific line API differences SDL_SetRenderDrawColor(renderer, 60, 120, 160, 120); SDL_FRect sep{ row.x + 6.0f, row.y + row.h - 1.0f, row.w - 12.0f, 1.0f }; SDL_RenderFillRect(renderer, &sep); // Highlight the selected row with a subtle outline if (idx == optionsSelectedRow) { SDL_SetRenderDrawColor(renderer, 80, 200, 255, 120); SDL_RenderRect(renderer, &row); } if (retroFont) { SDL_Color labelColor = SDL_Color{170, 210, 220, 255}; SDL_Color valueColor = SDL_Color{160, 240, 255, 255}; if (!label.empty()) { float labelScale = 1.0f; int labelW = 0, labelH = 0; retroFont->measure(label, labelScale, labelW, labelH); float labelY = row.y + (row.h - static_cast(labelH)) * 0.5f; retroFont->draw(renderer, row.x + 16.0f, labelY, label, labelScale, labelColor); } int valueW = 0, valueH = 0; float valueScale = 1.4f; retroFont->measure(value, valueScale, valueW, valueH); float valX = row.x + row.w - static_cast(valueW) - 16.0f; float valY = row.y + (row.h - valueH) * 0.5f; retroFont->draw(renderer, valX, valY, value, valueScale, valueColor); } }; float rowY = area.y + spacing; // FULLSCREEN bool isFS = ctx.fullscreenFlag ? *ctx.fullscreenFlag : Settings::instance().isFullscreen(); drawField(0, rowY, "FULLSCREEN", isFS ? "ON" : "OFF"); rowY += rowHeight + spacing; // MUSIC bool musicOn = ctx.musicEnabled ? *ctx.musicEnabled : Settings::instance().isMusicEnabled(); drawField(1, rowY, "MUSIC", musicOn ? "ON" : "OFF"); rowY += rowHeight + spacing; // SOUND FX bool soundOn = SoundEffectManager::instance().isEnabled(); drawField(2, rowY, "SOUND FX", soundOn ? "ON" : "OFF"); rowY += rowHeight + spacing; // SMOOTH SCROLL bool smooth = Settings::instance().isSmoothScrollEnabled(); drawField(3, rowY, "SMOOTH SCROLL", smooth ? "ON" : "OFF"); rowY += rowHeight + spacing; // RETURN TO MENU drawField(4, rowY, "", "RETURN TO MENU"); } // Draw inline help HUD (no boxed background) — match Options/Exit style if (helpTransition > 0.0) { float easedH = static_cast(helpTransition); easedH = easedH * easedH * (3.0f - 2.0f * easedH); const float PW = std::min(520.0f, LOGICAL_W * 0.65f); const float PH = std::min(420.0f, LOGICAL_H * 0.72f); float panelBaseX = (LOGICAL_W - PW) * 0.5f + contentOffsetX; float panelBaseY = (LOGICAL_H - PH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); float slideAmount = LOGICAL_H * 0.42f; float panelY = panelBaseY + (1.0f - easedH) * slideAmount; FontAtlas* f = ctx.pixelFont ? ctx.pixelFont : ctx.font; if (f) { // Header (smaller) f->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "HELP & SHORTCUTS", 1.25f, SDL_Color{255,220,0,255}); // Content layout (two columns) const float contentPadding = 16.0f; const float columnGap = 18.0f; const float columnWidth = (PW - contentPadding * 2.0f - columnGap) * 0.5f; const float leftX = panelBaseX + contentPadding; const float rightX = leftX + columnWidth + columnGap; // Shortcut entries (copied from HelpOverlay) struct ShortcutEntry { const char* combo; const char* description; }; const ShortcutEntry generalShortcuts[] = { {"F1", "Toggle this help overlay"}, {"ESC", "Back / cancel current popup"}, {"F11 or ALT+ENTER", "Toggle fullscreen"}, {"M", "Mute or unmute music"}, {"K", "Toggle sound effects"} }; const ShortcutEntry menuShortcuts[] = { {"ARROW KEYS", "Navigate menu buttons"}, {"ENTER / SPACE", "Activate highlighted action"} }; const ShortcutEntry gameplayShortcuts[] = { {"LEFT / RIGHT", "Move active piece"}, {"DOWN", "Soft drop (faster fall)"}, {"SPACE", "Hard drop / instant lock"}, {"UP", "Rotate clockwise"}, {"H", "Hold / swap current piece"}, {"X", "Toggle rotation direction used by UP"}, {"P", "Pause or resume"}, {"ESC", "Open exit confirmation"} }; // Helper to draw text with extra letter-spacing (tracking) auto drawSpaced = [&](float sx, float sy, const char* text, float scale, SDL_Color color, float extraPx) { std::string stext(text); float x = sx; for (size_t i = 0; i < stext.size(); ++i) { std::string ch(1, stext[i]); f->draw(renderer, x, sy, ch.c_str(), scale, color); int cw = 0, chh = 0; f->measure(ch.c_str(), scale, cw, chh); x += static_cast(cw) + extraPx; } }; auto drawSection = [&](float sx, float& cursorY, const char* title, const ShortcutEntry* entries, int count) { // Section title (smaller) with added letter spacing (reduced scale) drawSpaced(sx, cursorY, title, 0.85f, SDL_Color{180,200,255,255}, 4.0f); // Add extra gap after the headline so it separates clearly from the first row cursorY += 28.0f; for (int i = 0; i < count; ++i) { const auto &entry = entries[i]; // Combo/key label f->draw(renderer, sx, cursorY, entry.combo, 0.70f, SDL_Color{255,255,255,255}); // Slightly more space between the combo/key and the description cursorY += 26.0f; // Description (smaller) with increased spacing f->draw(renderer, sx + 6.0f, cursorY, entry.description, 0.62f, SDL_Color{200,210,230,255}); int w=0,h=0; f->measure(entry.description, 0.62f, w, h); cursorY += static_cast(h) + 16.0f; } // (rest of help render continues below) // Add a larger gap between sections cursorY += 22.0f; // Draw inline ABOUT HUD (no boxed background) — simple main info if (aboutTransition > 0.0) { float easedA = static_cast(aboutTransition); easedA = easedA * easedA * (3.0f - 2.0f * easedA); const float PW = std::min(520.0f, LOGICAL_W * 0.65f); const float PH = std::min(320.0f, LOGICAL_H * 0.60f); float panelBaseX = (LOGICAL_W - PW) * 0.5f + contentOffsetX; float panelBaseY = (LOGICAL_H - PH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); float slideAmount = LOGICAL_H * 0.42f; float panelY = panelBaseY + (1.0f - easedA) * slideAmount; FontAtlas* f = ctx.pixelFont ? ctx.pixelFont : ctx.font; if (f) { f->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "ABOUT", 1.25f, SDL_Color{255,220,0,255}); float x = panelBaseX + 16.0f; float y = panelY + 52.0f; const float lineGap = 30.0f; const SDL_Color textCol{200, 210, 230, 255}; const SDL_Color keyCol{255, 255, 255, 255}; 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; f->draw(renderer, x, y, "H Hold / swap current piece", 0.78f, textCol); y += lineGap; f->draw(renderer, x, y, "SPACE Hard drop", 0.78f, textCol); y += lineGap; f->draw(renderer, x, y, "P Pause", 0.78f, textCol); y += lineGap + 6.0f; f->draw(renderer, x, y, "UI", 0.85f, SDL_Color{180,200,255,255}); y += lineGap; f->draw(renderer, x, y, "F1 Toggle help overlay", 0.78f, textCol); y += lineGap; f->draw(renderer, x, y, "ESC Back / exit prompt", 0.78f, textCol); y += lineGap + 10.0f; f->draw(renderer, x, y, "PRESS ESC OR ARROW KEYS TO RETURN", 0.75f, SDL_Color{215,220,240,255}); } } }; const float contentTopY = panelY + 30.0f; float leftCursor = contentTopY - static_cast(helpScroll); float rightCursor = contentTopY - static_cast(helpScroll); drawSection(leftX, leftCursor, "GENERAL", generalShortcuts, (int)(sizeof(generalShortcuts)/sizeof(generalShortcuts[0]))); drawSection(leftX, leftCursor, "MENUS", menuShortcuts, (int)(sizeof(menuShortcuts)/sizeof(menuShortcuts[0]))); drawSection(rightX, rightCursor, "GAMEPLAY", gameplayShortcuts, (int)(sizeof(gameplayShortcuts)/sizeof(gameplayShortcuts[0]))); // Ensure helpScroll bounds (simple clamp) float contentHeight = std::max(leftCursor, rightCursor) - contentTopY; float maxScroll = std::max(0.0f, contentHeight - (PH - 120.0f)); if (helpScroll < 0.0) helpScroll = 0.0; if (helpScroll > maxScroll) helpScroll = maxScroll; } } // Draw inline level selector HUD (no background) if active if (levelTransition > 0.0) { float easedL = static_cast(levelTransition); easedL = easedL * easedL * (3.0f - 2.0f * easedL); const float PW = std::min(520.0f, LOGICAL_W * 0.65f); const float PH = std::min(360.0f, LOGICAL_H * 0.7f); float panelBaseX = (LOGICAL_W - PW) * 0.5f + contentOffsetX; float panelBaseY = (LOGICAL_H - PH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); float slideAmount = LOGICAL_H * 0.42f; float panelY = panelBaseY + (1.0f - easedL) * slideAmount; // Header FontAtlas* titleFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; if (titleFont) titleFont->draw(renderer, panelBaseX + PW * 0.5f - 140.0f, panelY + 6.0f, "SELECT STARTING LEVEL", 1.1f, SDL_Color{160,220,255,255}); // Grid area float marginX = 34.0f, marginY = 56.0f; SDL_FRect area{ panelBaseX + marginX, panelY + marginY, PW - 2.0f * marginX, PH - marginY - 28.0f }; const int cols = 4, rows = 5; const float gapX = 12.0f, gapY = 12.0f; float cellW = (area.w - (cols - 1) * gapX) / cols; float cellH = (area.h - (rows - 1) * gapY) / rows; for (int i = 0; i < 20; ++i) { int r = i / cols, c = i % cols; SDL_FRect rc{ area.x + c * (cellW + gapX), area.y + r * (cellH + gapY), cellW, cellH }; bool hovered = (levelSelected == i) || (levelHovered == i); bool selected = (ctx.startLevelSelection && *ctx.startLevelSelection == i); // Use the project's gold/yellow tone for selected level to match UI accents SDL_Color selectedFill = SDL_Color{255,204,0,160}; SDL_Color fill = selected ? selectedFill : (hovered ? SDL_Color{60,80,100,120} : SDL_Color{30,40,60,110}); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); SDL_RenderFillRect(renderer, &rc); SDL_SetRenderDrawColor(renderer, 80,100,120,160); SDL_RenderRect(renderer, &rc); // Draw level number FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; if (f) { char buf[8]; std::snprintf(buf, sizeof(buf), "%d", i); int w=0,h=0; f->measure(buf, 1.6f, w, h); f->draw(renderer, rc.x + (rc.w - w) * 0.5f, rc.y + (rc.h - h) * 0.5f, buf, 1.6f, SDL_Color{220,230,240,255}); } } // Draw animated highlight (interpolated) on top of cells if (levelHighlightInitialized) { float hx = (float)levelHighlightX; float hy = (float)levelHighlightY; float hw = cellW + 6.0f; float hh = cellH + 6.0f; // Draw multi-layer glow: outer faint, mid, inner bright SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); // Outer glow SDL_SetRenderDrawColor(renderer, levelHighlightColor.r, levelHighlightColor.g, levelHighlightColor.b, (Uint8)(levelHighlightGlowAlpha * 80)); SDL_FRect outer{ hx - (hw * 0.5f + 8.0f), hy - (hh * 0.5f + 8.0f), hw + 16.0f, hh + 16.0f }; SDL_RenderRect(renderer, &outer); // Mid glow SDL_SetRenderDrawColor(renderer, levelHighlightColor.r, levelHighlightColor.g, levelHighlightColor.b, (Uint8)(levelHighlightGlowAlpha * 140)); SDL_FRect mid{ hx - (hw * 0.5f + 4.0f), hy - (hh * 0.5f + 4.0f), hw + 8.0f, hh + 8.0f }; SDL_RenderRect(renderer, &mid); // Inner outline SDL_SetRenderDrawColor(renderer, levelHighlightColor.r, levelHighlightColor.g, levelHighlightColor.b, levelHighlightColor.a); SDL_FRect inner{ hx - hw * 0.5f, hy - hh * 0.5f, hw, hh }; // Draw multiple rects to simulate thickness for (int t = 0; t < levelHighlightThickness; ++t) { SDL_FRect r{ inner.x - t, inner.y - t, inner.w + t * 2.0f, inner.h + t * 2.0f }; SDL_RenderRect(renderer, &r); } } // Instructions FontAtlas* foot = ctx.font ? ctx.font : ctx.pixelFont; if (foot) foot->draw(renderer, panelBaseX + PW*0.5f - 160.0f, panelY + PH + 40.0f, "ARROWS = NAV • ENTER = SELECT • ESC = CANCEL", 0.9f, SDL_Color{160,180,200,200}); } // Trace exit { FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); } } }