feat(renderer): polish gameplay visuals — transport, starfield, sparkles, smooth piece motion

Add transport/transfer effect for NEXT → grid with cross-fade and preview swap
Integrate in-grid Starfield3D with magnet targeting tied to active piece
Spawn ambient sparkles and impact sparks (hard-drop crackle + burst on expiry)
Smooth horizontal/fall interpolation for active piece (configurable smooth scroll)
Refactor next panel / preview rendering and connector drawing
Tweak stats/score panel layout, progress bars and typography for compact view
Preserve safe alpha handling and restore renderer blend/scale state after overlays
This commit is contained in:
2025-12-08 20:43:51 +01:00
parent 815913b15b
commit 57eac01bcb
8 changed files with 293 additions and 48 deletions

View File

@ -1,6 +1,7 @@
#include "MenuState.h"
#include "persistence/Scores.h"
#include "graphics/Font.h"
#include "../graphics/ui/HelpOverlay.h"
#include "../core/GlobalState.h"
#include "../core/Settings.h"
#include "../core/state/StateManager.h"
@ -106,6 +107,21 @@ static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP
MenuState::MenuState(StateContext& ctx) : State(ctx) {}
void MenuState::showHelpPanel(bool show) {
if (show) {
if (!helpPanelVisible && !helpPanelAnimating) {
helpPanelAnimating = true;
helpDirection = 1;
helpScroll = 0.0;
}
} else {
if (helpPanelVisible && !helpPanelAnimating) {
helpPanelAnimating = true;
helpDirection = -1;
}
}
}
void MenuState::onEnter() {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState::onEnter called");
if (ctx.showExitConfirmPopup) {
@ -139,14 +155,15 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale,
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
struct MenuButtonDef { SDL_Color bg; SDL_Color border; std::string label; };
std::array<MenuButtonDef,4> buttons = {
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" }
};
std::array<SDL_Texture*,4> icons = { playIcon, levelIcon, optionsIcon, exitIcon };
std::array<SDL_Texture*,5> icons = { playIcon, levelIcon, optionsIcon, helpIcon, exitIcon };
float spacing = isSmall ? btnW * 1.2f : btnW * 1.15f;
@ -185,8 +202,8 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale,
groupCenterY = panelTop + panelH * 0.5f;
}
// Draw all four buttons on top
for (int i = 0; i < 4; ++i) {
// Draw all five buttons on top
for (int i = 0; i < 5; ++i) {
float cxCenter = 0.0f;
// Use the group's center Y so text/icons sit visually centered in the panel
float cyCenter = groupCenterY;
@ -194,12 +211,12 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale,
cxCenter = ctx.menuButtonCX[i] + contentOffsetX;
cyCenter = ctx.menuButtonCY[i] + contentOffsetY;
} else {
float offset = (static_cast<float>(i) - 1.5f) * spacing;
// Apply small per-button offsets that match the original placements
float offset = (static_cast<float>(i) - 2.0f) * spacing;
// small per-button offsets to better match original art placement
float extra = 0.0f;
if (i == 0) extra = 15.0f;
if (i == 2) extra = -24.0f;
if (i == 3) extra = -44.0f;
if (i == 2) extra = -18.0f;
if (i == 4) extra = -24.0f;
cxCenter = btnX + offset + extra;
}
// Apply group alpha and transient flash to button colors
@ -227,6 +244,7 @@ void MenuState::onExit() {
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; }
}
void MenuState::handleEvent(const SDL_Event& e) {
@ -373,6 +391,29 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
}
// 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_PAGEDOWN:
case SDL_SCANCODE_DOWN: {
helpScroll += 40.0; return;
}
case SDL_SCANCODE_PAGEUP:
case SDL_SCANCODE_UP: {
helpScroll -= 40.0; if (helpScroll < 0.0) helpScroll = 0.0; return;
}
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
@ -408,8 +449,8 @@ void MenuState::handleEvent(const SDL_Event& e) {
switch (e.key.scancode) {
case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_UP:
{
const int total = 4;
{
const int total = 5;
selectedButton = (selectedButton + total - 1) % total;
// brief bright flash on navigation
buttonFlash = 1.0;
@ -417,8 +458,8 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_DOWN:
{
const int total = 4;
{
const int total = 5;
selectedButton = (selectedButton + 1) % total;
// brief bright flash on navigation
buttonFlash = 1.0;
@ -457,6 +498,17 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
break;
case 3:
// 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 4:
// Show the inline exit HUD
if (!exitPanelVisible && !exitPanelAnimating) {
exitPanelAnimating = true;
@ -538,6 +590,21 @@ void MenuState::update(double frameMs) {
}
}
// 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;
}
}
// 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
@ -660,7 +727,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
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.
float combinedTransition = static_cast<float>(std::max(std::max(optionsTransition, levelTransition), exitTransition));
float combinedTransition = static_cast<float>(std::max(std::max(std::max(optionsTransition, levelTransition), exitTransition), helpTransition));
float eased = combinedTransition * combinedTransition * (3.0f - 2.0f * combinedTransition); // cubic smoothstep
float panelDelta = eased * moveAmount;
@ -829,18 +896,20 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
std::string label;
};
std::array<MenuButtonDef, 4> buttons = {
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*, 4> icons = {
std::array<SDL_Texture*, 5> icons = {
playIcon,
levelIcon,
optionsIcon,
helpIcon,
exitIcon
};
@ -852,7 +921,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
// Draw semi-transparent background panel behind the full button group
{
float groupCenterX = btnX;
float halfSpan = 1.5f * spacing;
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;
@ -936,12 +1005,29 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
cxCenter = ctx.menuButtonCX[i] + contentOffsetX;
cyCenter = ctx.menuButtonCY[i] + contentOffsetY;
} else {
float offset = (static_cast<float>(i) - 1.5f) * spacing;
cxCenter = btnX + offset - 44.0f;
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]);
}
}
}
}
@ -1084,6 +1170,100 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
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[] = {
{"H", "Toggle this help overlay"},
{"ESC", "Back / cancel current popup"},
{"F11 or ALT+ENTER", "Toggle fullscreen"},
{"M", "Mute or unmute music"},
{"S", "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"},
{"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;
}
// Add a larger gap between sections
cursorY += 22.0f;
};
float leftCursor = panelY + 48.0f - static_cast<float>(helpScroll);
float rightCursor = panelY + 48.0f - 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) - (panelY + 48.0f);
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);