2267 lines
109 KiB
C++
2267 lines
109 KiB
C++
#include "MenuState.h"
|
||
#include "persistence/Scores.h"
|
||
#include "../network/supabase_client.h"
|
||
#include "../network/NetSession.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/AudioManager.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>
|
||
#include <random>
|
||
|
||
// 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;
|
||
coopSetupStep = CoopSetupStep::ChoosePartner;
|
||
coopNetworkRoleSelected = 0;
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||
SDL_StopTextInput(focusWin);
|
||
}
|
||
if (coopNetworkSession) {
|
||
coopNetworkSession->shutdown();
|
||
coopNetworkSession.reset();
|
||
}
|
||
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;
|
||
|
||
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||
SDL_StopTextInput(focusWin);
|
||
}
|
||
|
||
// Cancel any pending network session if the coop setup is being closed.
|
||
if (coopNetworkSession) {
|
||
coopNetworkSession->shutdown();
|
||
coopNetworkSession.reset();
|
||
}
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
coopSetupStep = CoopSetupStep::ChoosePartner;
|
||
// Resume menu music only when requested (ESC should pass resumeMusic=false)
|
||
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
|
||
if (auto sys = AudioManager::get()) sys->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) {
|
||
// Text input for network IP entry (only while coop setup panel is active).
|
||
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_TEXT_INPUT) {
|
||
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
|
||
if (target.size() < 64) {
|
||
target += e.text.text;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
// Coop setup panel navigation (modal within the menu)
|
||
switch (e.key.scancode) {
|
||
case SDL_SCANCODE_UP:
|
||
case SDL_SCANCODE_DOWN:
|
||
// Do NOT allow up/down to change anything while this panel is active
|
||
return;
|
||
case SDL_SCANCODE_ESCAPE:
|
||
// When in a nested network step, go back one step; otherwise close the panel.
|
||
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||
coopSetupStep = CoopSetupStep::ChoosePartner;
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
return;
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||
SDL_StopTextInput(focusWin);
|
||
}
|
||
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
return;
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkWaiting) {
|
||
if (coopNetworkSession) {
|
||
coopNetworkSession->shutdown();
|
||
coopNetworkSession.reset();
|
||
}
|
||
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||
SDL_StopTextInput(focusWin);
|
||
}
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||
return;
|
||
}
|
||
showCoopSetupPanel(false, false);
|
||
return;
|
||
case SDL_SCANCODE_LEFT:
|
||
case SDL_SCANCODE_A:
|
||
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||
// 3-way selection: LOCAL / AI / NETWORK
|
||
coopSetupSelected = (coopSetupSelected + 3 - 1) % 3;
|
||
buttonFlash = 1.0;
|
||
return;
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||
coopNetworkRoleSelected = (coopNetworkRoleSelected + 2 - 1) % 2;
|
||
buttonFlash = 1.0;
|
||
return;
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
return;
|
||
}
|
||
return;
|
||
case SDL_SCANCODE_RIGHT:
|
||
case SDL_SCANCODE_D:
|
||
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||
coopSetupSelected = (coopSetupSelected + 1) % 3;
|
||
buttonFlash = 1.0;
|
||
return;
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||
coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2;
|
||
buttonFlash = 1.0;
|
||
return;
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
return;
|
||
}
|
||
return;
|
||
case SDL_SCANCODE_BACKSPACE:
|
||
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
|
||
if (!target.empty()) target.pop_back();
|
||
return;
|
||
}
|
||
break;
|
||
case SDL_SCANCODE_RETURN:
|
||
case SDL_SCANCODE_KP_ENTER:
|
||
case SDL_SCANCODE_SPACE:
|
||
{
|
||
// Existing flows (Local 2P / AI) are preserved exactly.
|
||
if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) {
|
||
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;
|
||
}
|
||
|
||
// Network flow (new): choose host/join, confirm connection before starting.
|
||
if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) {
|
||
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||
coopNetworkRoleSelected = 0;
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
if (coopNetworkSession) {
|
||
coopNetworkSession->shutdown();
|
||
coopNetworkSession.reset();
|
||
}
|
||
buttonFlash = 1.0;
|
||
return;
|
||
}
|
||
|
||
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||
// First, let the user enter the address (bind for host, remote for join).
|
||
coopSetupStep = CoopSetupStep::NetworkEnterAddress;
|
||
coopNetworkStatusText.clear();
|
||
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||
SDL_StartTextInput(focusWin);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
coopNetworkHandshakeSent = false;
|
||
coopNetworkStatusText.clear();
|
||
coopNetworkSession = std::make_unique<NetSession>();
|
||
|
||
const uint16_t port = coopNetworkPort;
|
||
bool ok = false;
|
||
if (coopNetworkRoleSelected == 0) {
|
||
const std::string bindIp = coopNetworkBindAddress;
|
||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST start bind=%s port=%u", bindIp.c_str(), (unsigned)port);
|
||
ok = coopNetworkSession->host(bindIp, port);
|
||
coopNetworkStatusText = ok ? "WAITING FOR PLAYER..." : ("HOST FAILED: " + coopNetworkSession->lastError());
|
||
} else {
|
||
const std::string joinIp = coopNetworkJoinAddress.empty() ? std::string("127.0.0.1") : coopNetworkJoinAddress;
|
||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] JOIN start remote=%s port=%u", joinIp.c_str(), (unsigned)port);
|
||
ok = coopNetworkSession->join(joinIp, port);
|
||
coopNetworkStatusText = ok ? "CONNECTING..." : ("JOIN FAILED: " + coopNetworkSession->lastError());
|
||
}
|
||
|
||
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||
SDL_StopTextInput(focusWin);
|
||
}
|
||
|
||
if (ok) {
|
||
coopSetupStep = CoopSetupStep::NetworkWaiting;
|
||
} else {
|
||
// Stay on role choice screen so user can back out.
|
||
coopNetworkSession.reset();
|
||
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// While waiting for connection, Enter does nothing.
|
||
return;
|
||
}
|
||
default:
|
||
// Allow other keys, 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) {
|
||
// Transient network status message (e.g., disconnect) shown on return to menu.
|
||
if (ctx.coopNetUiStatusRemainingMs > 0.0) {
|
||
ctx.coopNetUiStatusRemainingMs -= frameMs;
|
||
if (ctx.coopNetUiStatusRemainingMs <= 0.0) {
|
||
ctx.coopNetUiStatusRemainingMs = 0.0;
|
||
ctx.coopNetUiStatusText.clear();
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
if (!ctx.coopNetUiStatusText.empty() && ctx.coopNetUiStatusRemainingMs > 0.0) {
|
||
float msgScale = 0.75f;
|
||
int mW = 0, mH = 0;
|
||
useFont->measure(ctx.coopNetUiStatusText, msgScale, mW, mH);
|
||
float msgX = (LOGICAL_W - (float)mW) * 0.5f + contentOffsetX;
|
||
useFont->draw(renderer, msgX, scoresStartY, ctx.coopNetUiStatusText, msgScale, SDL_Color{255, 224, 130, 255});
|
||
scoresStartY += (float)mH + 10.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 or mouse hover.
|
||
// Prefer `hoveredButton` (mouse-over) when available so the TOP PLAYER panel
|
||
// updates responsively while the user moves the pointer over the bottom menu.
|
||
int activeBtn = (ctx.hoveredButton ? *ctx.hoveredButton : -1);
|
||
if (activeBtn < 0) activeBtn = selectedButton;
|
||
std::string wantedType = "classic";
|
||
if (activeBtn == 0) wantedType = "classic"; // Play / Endless
|
||
else if (activeBtn == 1) wantedType = "cooperate"; // Coop
|
||
else if (activeBtn == 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;
|
||
|
||
// Choice buttons (partner selection) and nested network host/join UI
|
||
const float btnH2 = 60.0f;
|
||
const float gap = 34.0f;
|
||
const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f);
|
||
const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f;
|
||
// Shift the image and buttons slightly for layout balance
|
||
const float shiftX = 20.0f;
|
||
const float bx = panelBaseX + (panelW - totalChoiceW) * 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 };
|
||
coopSetupBtnRects[2] = SDL_FRect{ bx + (btnW2 + gap) * 2.0f, 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 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;
|
||
// Compute coop info image placement (draw as background for both ChoosePartner and Network steps)
|
||
float imgX = 0.0f, imgY = 0.0f, targetW = 0.0f, targetH = 0.0f;
|
||
bool hasCoopImg = false;
|
||
if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) {
|
||
float totalW = totalChoiceW;
|
||
// Keep coop info image slightly smaller than the button row.
|
||
// Use a modest scale so it doesn't dominate the UI.
|
||
float maxImgW = totalW * 0.65f;
|
||
targetW = std::min(maxImgW, static_cast<float>(coopInfoTexW));
|
||
float scale = targetW / static_cast<float>(coopInfoTexW);
|
||
targetH = static_cast<float>(coopInfoTexH) * scale;
|
||
imgX = bx + (totalW - targetW) * 0.5f;
|
||
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);
|
||
hasCoopImg = true;
|
||
|
||
// Only draw the instructional overlay text when choosing partner.
|
||
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||
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));
|
||
|
||
// Step 1: choose partner mode
|
||
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||
coopSetupBtnRects[0].x + btnW2 * 0.5f,
|
||
coopSetupBtnRects[0].y + btnH2 * 0.5f,
|
||
btnW2, btnH2,
|
||
"LOCAL CO-OP",
|
||
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,
|
||
"AI PARTNER",
|
||
false,
|
||
coopSetupSelected == 1,
|
||
bgA,
|
||
borderA,
|
||
false,
|
||
nullptr);
|
||
|
||
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||
coopSetupBtnRects[2].x + btnW2 * 0.5f,
|
||
coopSetupBtnRects[2].y + btnH2 * 0.5f,
|
||
btnW2, btnH2,
|
||
"2 PLAYER (NET)",
|
||
false,
|
||
coopSetupSelected == 2,
|
||
bgA,
|
||
borderA,
|
||
false,
|
||
nullptr);
|
||
}
|
||
|
||
// Step 2: network host/join selection and address entry
|
||
if (coopSetupStep == CoopSetupStep::NetworkChooseRole || coopSetupStep == CoopSetupStep::NetworkEnterAddress || coopSetupStep == CoopSetupStep::NetworkWaiting) {
|
||
// Draw two buttons centered under the main row.
|
||
const float roleBtnW = std::min(280.0f, panelW * 0.30f);
|
||
const float roleGap = 48.0f;
|
||
const float roleTotalW = roleBtnW * 2.0f + roleGap;
|
||
const float roleX = panelBaseX + (panelW - roleTotalW) * 0.5f + shiftX;
|
||
// Move the host/join buttons down from the previous higher position.
|
||
// Shift down by one button height plus half a button (effectively lower them):
|
||
const float roleY = by + (btnH2 * 0.5f) - 18.0f;
|
||
|
||
SDL_FRect hostRect{ roleX, roleY, roleBtnW, btnH2 };
|
||
SDL_FRect joinRect{ roleX + roleBtnW + roleGap, roleY, roleBtnW, btnH2 };
|
||
|
||
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||
hostRect.x + roleBtnW * 0.5f,
|
||
hostRect.y + btnH2 * 0.5f,
|
||
roleBtnW,
|
||
btnH2,
|
||
"HOST GAME",
|
||
false,
|
||
coopNetworkRoleSelected == 0,
|
||
bgA,
|
||
borderA,
|
||
false,
|
||
nullptr);
|
||
|
||
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||
joinRect.x + roleBtnW * 0.5f,
|
||
joinRect.y + btnH2 * 0.5f,
|
||
roleBtnW,
|
||
btnH2,
|
||
"JOIN GAME",
|
||
false,
|
||
coopNetworkRoleSelected == 1,
|
||
bgA,
|
||
borderA,
|
||
false,
|
||
nullptr);
|
||
|
||
FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont;
|
||
if (f) {
|
||
SDL_Color infoCol{200, 220, 230, static_cast<Uint8>(std::round(220.0f * buttonFade))};
|
||
// Draw connection info on separate lines and shift right by ~200px
|
||
char portLine[64];
|
||
std::snprintf(portLine, sizeof(portLine), "PORT %u", (unsigned)coopNetworkPort);
|
||
char hostLine[128];
|
||
std::snprintf(hostLine, sizeof(hostLine), "HOST IP %s", coopNetworkBindAddress.c_str());
|
||
char joinLine[128];
|
||
std::snprintf(joinLine, sizeof(joinLine), "JOIN IP %s", coopNetworkJoinAddress.c_str());
|
||
|
||
const float textShiftX = 200.0f;
|
||
const float textX = panelBaseX + 60.0f + textShiftX;
|
||
const float endpointY = (hasCoopImg ? (imgY + targetH * 0.62f) : (roleY + btnH2 + 12.0f));
|
||
const float lineSpacing = 28.0f;
|
||
// Show only the minimal info needed for the selected role.
|
||
f->draw(renderer, textX, endpointY, portLine, 0.90f, infoCol);
|
||
if (coopNetworkRoleSelected == 0) {
|
||
// Host: show bind address only
|
||
f->draw(renderer, textX, endpointY + lineSpacing, hostLine, 0.90f, infoCol);
|
||
} else {
|
||
// Client: show join target only
|
||
f->draw(renderer, textX, endpointY + lineSpacing, joinLine, 0.90f, infoCol);
|
||
}
|
||
|
||
float hintY = endpointY + lineSpacing * 2.0f + 6.0f;
|
||
|
||
// Bottom helper prompt: show a compact instruction under the image window
|
||
float bottomY = hasCoopImg ? (imgY + targetH + 18.0f) : (hintY + 36.0f);
|
||
SDL_Color bottomCol{180,200,210,200};
|
||
if (coopNetworkRoleSelected == 0) {
|
||
f->draw(renderer, textX, bottomY, "HOST: press ENTER to edit bind IP, then press ENTER to confirm", 0.82f, bottomCol);
|
||
} else {
|
||
f->draw(renderer, textX, bottomY, "JOIN: press ENTER to type server IP, then press ENTER to connect", 0.82f, bottomCol);
|
||
}
|
||
if (coopSetupStep == CoopSetupStep::NetworkWaiting && !coopNetworkStatusText.empty()) {
|
||
SDL_Color statusCol{255, 215, 80, static_cast<Uint8>(std::round(240.0f * buttonFade))};
|
||
f->draw(renderer, textX, hintY, coopNetworkStatusText, 1.00f, statusCol);
|
||
} else if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
|
||
const char* label = (coopNetworkRoleSelected == 0) ? "TYPE HOST IP (BIND) THEN ENTER" : "TYPE JOIN IP THEN ENTER";
|
||
f->draw(renderer, textX, hintY, label, 0.82f, hintCol);
|
||
} else {
|
||
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
|
||
f->draw(renderer, textX, hintY, "PRESS ENTER TO EDIT/CONFIRM ESC TO GO BACK", 0.82f, hintCol);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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); }
|
||
}
|
||
|
||
// Network coop flow polling (non-blocking)
|
||
if (coopSetupAnimating || coopSetupVisible) {
|
||
if (coopSetupStep == CoopSetupStep::NetworkWaiting && coopNetworkSession) {
|
||
coopNetworkSession->poll(0);
|
||
|
||
// Update status depending on connection and role.
|
||
if (!coopNetworkSession->isConnected()) {
|
||
// Keep existing text (WAITING/CONNECTING) unless an error occurs.
|
||
} else {
|
||
// Host sends handshake after peer connects.
|
||
if (coopNetworkRoleSelected == 0 && !coopNetworkHandshakeSent) {
|
||
std::random_device rd;
|
||
uint32_t seed = static_cast<uint32_t>(rd());
|
||
if (seed == 0u) seed = 1u;
|
||
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||
NetSession::Handshake hs{ seed, 0u, startLevel };
|
||
if (coopNetworkSession->sendHandshake(hs)) {
|
||
coopNetworkHandshakeSent = true;
|
||
ctx.coopNetRngSeed = seed;
|
||
coopNetworkStatusText = "CONNECTED";
|
||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST handshake sent seed=%u level=%u", seed, (unsigned)startLevel);
|
||
} else {
|
||
coopNetworkStatusText = "HANDSHAKE FAILED";
|
||
}
|
||
}
|
||
|
||
// Client waits for handshake.
|
||
if (coopNetworkRoleSelected == 1) {
|
||
auto hs = coopNetworkSession->takeReceivedHandshake();
|
||
if (hs.has_value()) {
|
||
coopNetworkStatusText = "CONNECTED";
|
||
coopNetworkHandshakeSent = true;
|
||
ctx.coopNetRngSeed = hs->rngSeed;
|
||
if (ctx.startLevelSelection) {
|
||
*ctx.startLevelSelection = static_cast<int>(hs->startLevel);
|
||
}
|
||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] CLIENT handshake recv seed=%u level=%u", hs->rngSeed, (unsigned)hs->startLevel);
|
||
} else {
|
||
coopNetworkStatusText = "CONNECTED - WAITING FOR HOST...";
|
||
}
|
||
}
|
||
|
||
// Confirmed connection => start COOPERATE (network) session.
|
||
// Note: gameplay/network input injection is implemented separately.
|
||
if (coopNetworkHandshakeSent) {
|
||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||
"[NET COOP] START gameplay (role=%s localIsLeft=%d seed=%u level=%u)",
|
||
(coopNetworkRoleSelected == 0 ? "HOST" : "CLIENT"),
|
||
(coopNetworkRoleSelected == 0 ? 1 : 0),
|
||
(unsigned)ctx.coopNetRngSeed,
|
||
(unsigned)(ctx.startLevelSelection ? *ctx.startLevelSelection : 0));
|
||
// Hand off the session to gameplay.
|
||
if (ctx.coopNetSession) {
|
||
ctx.coopNetSession->shutdown();
|
||
ctx.coopNetSession.reset();
|
||
}
|
||
|
||
ctx.coopNetEnabled = true;
|
||
ctx.coopNetIsHost = (coopNetworkRoleSelected == 0);
|
||
ctx.coopNetLocalIsLeft = (coopNetworkRoleSelected == 0);
|
||
ctx.coopNetTick = 0;
|
||
ctx.coopNetPendingButtons = 0;
|
||
ctx.coopNetDesyncDetected = false;
|
||
|
||
const uint32_t seed = (ctx.coopNetRngSeed == 0u) ? 1u : ctx.coopNetRngSeed;
|
||
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||
|
||
if (ctx.coopVsAI) {
|
||
*ctx.coopVsAI = false;
|
||
}
|
||
if (ctx.game) {
|
||
ctx.game->setMode(GameMode::Cooperate);
|
||
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||
}
|
||
if (ctx.coopGame) {
|
||
// Deterministic reset for network coop.
|
||
ctx.coopGame->resetDeterministic(startLevel, seed);
|
||
}
|
||
|
||
// Transfer ownership of the active session.
|
||
ctx.coopNetSession = std::move(coopNetworkSession);
|
||
|
||
// Close the panel without restarting menu music; gameplay will take over.
|
||
showCoopSetupPanel(false, false);
|
||
|
||
// For network lockstep, do NOT run the menu->play countdown/fade.
|
||
// Any local countdown introduces drift and stalls.
|
||
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||
if (ctx.game) ctx.game->setPaused(false);
|
||
|
||
if (ctx.stateManager) {
|
||
ctx.stateManager->setState(AppState::Playing);
|
||
} else if (ctx.startPlayTransition) {
|
||
// Fallback if state manager is unavailable.
|
||
ctx.startPlayTransition();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|