Files
spacetris/src/states/MenuState.cpp
2025-12-23 14:49:55 +01:00

1846 lines
88 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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 <SDL3/SDL.h>
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_surface.h>
#include <cstdio>
#include <algorithm>
#include <array>
#include <cmath>
#include <vector>
// 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 <SDL3_image/SDL_image.h>
// 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<int>(std::floor(panelTop * logicalScale + logicalVP.y));
int panelWinW = sceneW; // full width of captured scene
int panelWinH = static_cast<int>(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<int>(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<ScoreEntry> 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<int>(LOGICAL_W),
static_cast<int>(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<float>(e.button.x);
const float my = static_cast<float>(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<double>(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<double>(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<double>(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<double>(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<double>(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<double>(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<float>(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<ScoreEntry> 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<ScoreEntry> 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<Uint8>(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<MenuButtonDef,5> 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<SDL_Texture*, 5> 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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(coopInfoTexW));
float scale = targetW / static_cast<float>(coopInfoTexW);
float targetH = static_cast<float>(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<Uint8>(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<std::string> 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<Uint8>(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<float>(hW)) * 0.5f + 40.0f; // nudge header right by 40px
float headerY = textY - static_cast<float>(sampleLH);
SDL_Color headerCol = SDL_Color{220,240,255,230}; headerCol.a = static_cast<Uint8>(std::round(headerCol.a * alphaFactor));
f->draw(renderer, hx, headerY, header, headerScale, headerCol);
// Start body text slightly below header
textY = headerY + static_cast<float>(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<float>(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<Uint8>(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<float>(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<Uint8>(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<Uint8>(std::round(bgA.a * buttonFade));
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(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<float>(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<std::string> 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<Uint8>(std::round(aMul * static_cast<double>(yesBg.a)));
yesBorder.a = static_cast<Uint8>(std::round(aMul * static_cast<double>(yesBorder.a)));
noBg.a = static_cast<Uint8>(std::round(aMul * static_cast<double>(noBg.a)));
noBorder.a = static_cast<Uint8>(std::round(aMul * static_cast<double>(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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(helpScroll);
float rightCursor = contentTopY - static_cast<float>(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<float>(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); }
}
}