feat: Add Firebase high score sync, menu music, and gameplay improvements

- Integrate Firebase Realtime Database for high score synchronization
  - Add cpr and nlohmann-json dependencies for HTTP requests
  - Implement async score loading from Firebase with local fallback
  - Submit all scores > 0 to Firebase in background thread
  - Always prompt for player name on game over if score > 0

- Add dedicated menu music system
  - Implement menu track support in Audio class with looping
  - Add "Every Block You Take.mp3" as main menu theme
  - Automatically switch between menu and game music on state transitions
  - Load menu track asynchronously to prevent startup delays

- Update level speed progression to match web version
  - Replace NES frame-based gravity with explicit millisecond values
  - Implement 20-level speed table (1000ms to 60ms)
  - Ensure consistent gameplay between C++ and web versions

- Fix startup performance issues
  - Move score loading to background thread to prevent UI freeze
  - Optimize Firebase network requests with 2s timeout
  - Add graceful fallback to local scores on network failure

Files modified:
- src/persistence/Scores.cpp/h - Firebase integration
- src/audio/Audio.cpp/h - Menu music support
- src/core/GravityManager.cpp/h - Level speed updates
- src/main.cpp - State-based music switching, async loading
- CMakeLists.txt - Add cpr and nlohmann-json dependencies
- vcpkg.json - Update dependency list
This commit is contained in:
2025-11-22 09:47:46 +01:00
parent 66099809e0
commit ec2bb1bb1e
20 changed files with 387 additions and 2316 deletions

View File

@ -29,6 +29,7 @@
#include "states/LevelSelectorState.h"
#include "states/PlayingState.h"
#include "audio/MenuWrappers.h"
#include "graphics/renderers/GameRenderer.h"
// Debug logging removed: no-op in this build (previously LOG_DEBUG)
@ -278,6 +279,8 @@ static bool showSettingsPopup = false;
static bool showExitConfirmPopup = false;
static bool musicEnabled = true;
static int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
static bool isNewHighScore = false;
static std::string playerName = "";
// -----------------------------------------------------------------------------
// Tetris Block Fireworks for intro animation (block particles)
@ -437,7 +440,10 @@ int main(int, char **)
pixelFont.init("assets/fonts/PressStart2P-Regular.ttf", 16);
ScoreManager scores;
scores.load();
// Load scores asynchronously to prevent startup hang due to network request
std::thread([&scores]() {
scores.load();
}).detach();
Starfield starfield;
starfield.init(200, LOGICAL_W, LOGICAL_H);
Starfield3D starfield3D;
@ -726,6 +732,37 @@ int main(int, char **)
SDL_SetWindowFullscreen(window, isFullscreen ? SDL_WINDOW_FULLSCREEN : 0);
}
}
// Text input for high score
if (state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
if (playerName.length() < 12) {
playerName += e.text.text;
}
}
if (state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (isNewHighScore) {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
playerName.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (playerName.empty()) playerName = "PLAYER";
scores.submit(game.score(), game.lines(), game.level(), game.elapsed(), playerName);
isNewHighScore = false;
SDL_StopTextInput(window);
}
} else {
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
// Restart
game.reset(startLevelSelection);
state = AppState::Playing;
stateMgr.setState(state);
} else if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
// Menu
state = AppState::Menu;
stateMgr.setState(state);
}
}
}
// Mouse handling remains in main loop for UI interactions
if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN)
@ -923,7 +960,15 @@ int main(int, char **)
}
if (game.isGameOver())
{
scores.submit(game.score(), game.lines(), game.level(), game.elapsed());
// Always allow name entry if score > 0
if (game.score() > 0) {
isNewHighScore = true; // Reuse flag to trigger input mode
playerName = "";
SDL_StartTextInput(window);
} else {
isNewHighScore = false;
scores.submit(game.score(), game.lines(), game.level(), game.elapsed());
}
state = AppState::GameOver;
stateMgr.setState(state);
}
@ -1006,12 +1051,38 @@ int main(int, char **)
{
if (!musicStarted && musicLoaded)
{
// Music tracks are already loaded during loading screen, just start playback
Audio::instance().start();
// Load menu track once on first menu entry (in background to avoid blocking)
static bool menuTrackLoaded = false;
if (!menuTrackLoaded) {
std::thread([]() {
Audio::instance().setMenuTrack("assets/music/Every Block You Take.mp3");
}).detach();
menuTrackLoaded = true;
}
// Start appropriate music based on state
if (state == AppState::Menu) {
Audio::instance().playMenuMusic();
} else {
Audio::instance().playGameMusic();
}
musicStarted = true;
}
}
// Handle music transitions between states
static AppState previousState = AppState::Loading;
if (state != previousState && musicStarted) {
if (state == AppState::Menu && previousState == AppState::Playing) {
// Switched from game to menu
Audio::instance().playMenuMusic();
} else if (state == AppState::Playing && previousState == AppState::Menu) {
// Switched from menu to game
Audio::instance().playGameMusic();
}
}
previousState = state;
// Update starfields based on current state
if (state == AppState::Loading) {
starfield3D.update(float(frameMs / 1000.0f));
@ -1242,409 +1313,141 @@ int main(int, char **)
}
break;
case AppState::Playing:
{
// Calculate actual content area (centered within the window)
float contentScale = logicalScale;
float contentW = LOGICAL_W * contentScale;
float contentH = LOGICAL_H * contentScale;
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
// Draw the game with layout matching the JavaScript version
auto drawRect = [&](float x, float y, float w, float h, SDL_Color c)
GameRenderer::renderPlayingState(
renderer,
&game,
&pixelFont,
&lineEffect,
blocksTex,
(float)LOGICAL_W,
(float)LOGICAL_H,
logicalScale,
(float)winW,
(float)winH,
showExitConfirmPopup
);
break;
case AppState::GameOver:
// Draw the game state in the background
GameRenderer::renderPlayingState(
renderer,
&game,
&pixelFont,
&lineEffect,
blocksTex,
(float)LOGICAL_W,
(float)LOGICAL_H,
logicalScale,
(float)winW,
(float)winH,
false // No exit popup in Game Over
);
// Draw Game Over Overlay
{
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
SDL_RenderFillRect(renderer, &fr);
};
// Responsive layout that scales with window size while maintaining margins
// Calculate available space considering UI panels and margins
const float MIN_MARGIN = 40.0f; // Minimum margin from edges
const float TOP_MARGIN = 60.0f; // Extra top margin for better spacing
const float PANEL_WIDTH = 180.0f; // Width of side panels
const float PANEL_SPACING = 30.0f; // Space between grid and panels
const float NEXT_PIECE_HEIGHT = 120.0f; // Space reserved for next piece preview (increased)
const float BOTTOM_MARGIN = 60.0f; // Space for controls text at bottom
// Available width = Total width - margins - left panel - right panel - spacing
const float availableWidth = LOGICAL_W - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
const float availableHeight = LOGICAL_H - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;
// Calculate block size based on available space (maintain 10:20 aspect ratio)
const float maxBlockSizeW = availableWidth / Game::COLS;
const float maxBlockSizeH = availableHeight / Game::ROWS;
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
// Ensure minimum and maximum block sizes
const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f));
const float GRID_W = Game::COLS * finalBlockSize;
const float GRID_H = Game::ROWS * finalBlockSize;
// Calculate vertical position with proper top margin
const float totalContentHeight = NEXT_PIECE_HEIGHT + GRID_H;
const float availableVerticalSpace = LOGICAL_H - TOP_MARGIN - BOTTOM_MARGIN;
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
// Perfect horizontal centering - center the entire layout (grid + panels) in the window
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
const float layoutStartX = (LOGICAL_W - totalLayoutWidth) * 0.5f;
// Calculate panel and grid positions from the centered layout
const float statsX = layoutStartX + contentOffsetX;
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
const float scoreX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + contentOffsetX;
// Position grid with proper top spacing
const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY;
// Panel dimensions and positions
const float statsY = gridY;
const float statsW = PANEL_WIDTH;
const float statsH = GRID_H;
const float scoreY = gridY;
const float scoreW = PANEL_WIDTH;
// Next piece preview (above grid, centered)
const float nextW = finalBlockSize * 4 + 20;
const float nextH = finalBlockSize * 2 + 20;
const float nextX = gridX + (GRID_W - nextW) * 0.5f;
const float nextY = contentStartY + contentOffsetY;
// Handle line clearing effects (now that we have grid coordinates)
if (game.hasCompletedLines() && !lineEffect.isActive()) {
auto completedLines = game.getCompletedLines();
lineEffect.startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
}
// Draw panels with borders (like JS version)
// Game grid border
drawRect(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); // Outer border
drawRect(gridX - 1 - contentOffsetX, gridY - 1 - contentOffsetY, GRID_W + 2, GRID_H + 2, {60, 80, 160, 255}); // Inner border
drawRect(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); // Background
// Left panel background (BLOCKS panel) - translucent, slightly shorter height
{
SDL_SetRenderDrawColor(renderer, 10, 15, 25, 160);
SDL_FRect lbg{statsX - 16, gridY - 10, statsW + 32, GRID_H + 20};
SDL_RenderFillRect(renderer, &lbg);
}
// Right panel background (SCORE/LINES/LEVEL etc) - translucent
{
SDL_SetRenderDrawColor(renderer, 10, 15, 25, 160);
SDL_FRect rbg{scoreX - 16, gridY - 16, scoreW + 32, GRID_H + 32};
SDL_RenderFillRect(renderer, &rbg);
}
// Draw grid lines (subtle lines to show cell boundaries)
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); // Slightly lighter than background
// Vertical grid lines
for (int x = 1; x < Game::COLS; ++x) {
float lineX = gridX + x * finalBlockSize; // Remove duplicate contentOffsetX
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
}
// Horizontal grid lines
for (int y = 1; y < Game::ROWS; ++y) {
float lineY = gridY + y * finalBlockSize; // Remove duplicate contentOffsetY
SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY);
}
// Block statistics panel
drawRect(statsX - 3 - contentOffsetX, statsY - 3 - contentOffsetY, statsW + 6, statsH + 6, {100, 120, 200, 255});
drawRect(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255});
// Next piece preview panel
drawRect(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255});
drawRect(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255});
// Draw the game board
const auto &board = game.boardRef();
for (int y = 0; y < Game::ROWS; ++y)
{
for (int x = 0; x < Game::COLS; ++x)
{
int v = board[y * Game::COLS + x];
if (v > 0) {
float bx = gridX + x * finalBlockSize;
float by = gridY + y * finalBlockSize;
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1);
}
}
}
// Draw ghost piece (where current piece will land)
if (!game.isPaused()) {
Game::Piece ghostPiece = game.current();
// Find landing position
while (true) {
Game::Piece testPiece = ghostPiece;
testPiece.y++;
bool collision = false;
// Simple collision check
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (Game::cellFilled(testPiece, cx, cy)) {
int gx = testPiece.x + cx;
int gy = testPiece.y + cy;
if (gy >= Game::ROWS || gx < 0 || gx >= Game::COLS ||
(gy >= 0 && board[gy * Game::COLS + gx] != 0)) {
collision = true;
break;
}
}
}
if (collision) break;
}
if (collision) break;
ghostPiece = testPiece;
}
// Draw ghost piece
drawPiece(renderer, blocksTex, ghostPiece, gridX, gridY, finalBlockSize, true);
}
// Draw the falling piece
if (!game.isPaused()) {
drawPiece(renderer, blocksTex, game.current(), gridX, gridY, finalBlockSize, false);
}
// Handle line clearing effects
if (game.hasCompletedLines() && !lineEffect.isActive()) {
lineEffect.startLineClear(game.getCompletedLines(), static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
}
// Draw line clearing effects
if (lineEffect.isActive()) {
lineEffect.render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
}
// Draw next piece preview
pixelFont.draw(renderer, nextX + 10, nextY - 20, "NEXT", 1.0f, {255, 220, 0, 255});
if (game.next().type < PIECE_COUNT) {
drawSmallPiece(renderer, blocksTex, game.next().type, nextX + 10, nextY + 10, finalBlockSize * 0.6f);
}
// Draw block statistics (left panel)
pixelFont.draw(renderer, statsX + 10, statsY + 10, "BLOCKS", 1.0f, {255, 220, 0, 255});
const auto& blockCounts = game.getBlockCounts();
int totalBlocks = 0; for (int i = 0; i < PIECE_COUNT; ++i) totalBlocks += blockCounts[i];
const char* pieceNames[] = {"I", "O", "T", "S", "Z", "J", "L"};
// Dynamic vertical cursor so bars sit below blocks cleanly
float yCursor = statsY + 52;
for (int i = 0; i < PIECE_COUNT; ++i) {
// Baseline for this entry
float py = yCursor;
// Draw small piece icon (top of entry)
float previewSize = finalBlockSize * 0.55f;
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(i), statsX + 18, py, previewSize);
// Compute preview height in tiles (rotation 0)
int maxCy = -1;
{
Game::Piece prev; prev.type = static_cast<PieceType>(i); prev.rot = 0; prev.x = 0; prev.y = 0;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (Game::cellFilled(prev, cx, cy)) maxCy = std::max(maxCy, cy);
}
}
}
int tilesHigh = (maxCy >= 0 ? maxCy + 1 : 1);
float previewHeight = tilesHigh * previewSize;
// Count on the right, near the top (aligned with blocks)
int count = blockCounts[i];
char countStr[16];
snprintf(countStr, sizeof(countStr), "%d", count);
pixelFont.draw(renderer, statsX + statsW - 20, py + 6, countStr, 1.1f, {240, 240, 245, 255});
// Percentage and bar BELOW the blocks
int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(count) / double(totalBlocks))) : 0;
char percStr[16];
snprintf(percStr, sizeof(percStr), "%d%%", perc);
float barX = statsX + 12;
float barY = py + previewHeight + 18.0f;
float barW = statsW - 24;
float barH = 6;
// Percent text just above the bar (left)
pixelFont.draw(renderer, barX, barY - 16, percStr, 0.8f, {230, 230, 235, 255});
// Track
SDL_SetRenderDrawColor(renderer, 170, 170, 175, 200);
SDL_FRect track{barX, barY, barW, barH};
SDL_RenderFillRect(renderer, &track);
// Fill (piece color)
SDL_Color pc = COLORS[i + 1];
SDL_SetRenderDrawColor(renderer, pc.r, pc.g, pc.b, 230);
float fillW = barW * (perc / 100.0f);
if (fillW < 0) fillW = 0; if (fillW > barW) fillW = barW;
SDL_FRect fill{barX, barY, fillW, barH};
SDL_RenderFillRect(renderer, &fill);
// Advance cursor: bar bottom + spacing
yCursor = barY + barH + 18.0f;
}
// Draw score panel (right side), centered vertically in grid
// Compute content vertical centering based on known offsets
const float contentTopOffset = 0.0f;
const float contentBottomOffset = 290.0f; // last line (time value)
const float contentPad = 36.0f;
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
pixelFont.draw(renderer, scoreX, baseY + 0, "SCORE", 1.0f, {255, 220, 0, 255});
char scoreStr[32];
snprintf(scoreStr, sizeof(scoreStr), "%d", game.score());
pixelFont.draw(renderer, scoreX, baseY + 25, scoreStr, 0.9f, {255, 255, 255, 255});
pixelFont.draw(renderer, scoreX, baseY + 70, "LINES", 1.0f, {255, 220, 0, 255});
char linesStr[16];
snprintf(linesStr, sizeof(linesStr), "%03d", game.lines());
pixelFont.draw(renderer, scoreX, baseY + 95, linesStr, 0.9f, {255, 255, 255, 255});
pixelFont.draw(renderer, scoreX, baseY + 140, "LEVEL", 1.0f, {255, 220, 0, 255});
char levelStr[16];
snprintf(levelStr, sizeof(levelStr), "%02d", game.level());
pixelFont.draw(renderer, scoreX, baseY + 165, levelStr, 0.9f, {255, 255, 255, 255});
// Next level progress
// JS rules: first threshold = (startLevel+1)*10; afterwards every +10
int startLv = game.startLevelBase(); // 0-based
int firstThreshold = (startLv + 1) * 10;
int linesDone = game.lines();
int nextThreshold = 0;
if (linesDone < firstThreshold) {
nextThreshold = firstThreshold;
} else {
int blocksPast = linesDone - firstThreshold;
nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
}
int linesForNext = std::max(0, nextThreshold - linesDone);
pixelFont.draw(renderer, scoreX, baseY + 200, "NEXT LVL", 1.0f, {255, 220, 0, 255});
char nextStr[32];
snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
pixelFont.draw(renderer, scoreX, baseY + 225, nextStr, 0.9f, {80, 255, 120, 255});
// Time
pixelFont.draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255});
int totalSecs = static_cast<int>(game.elapsed());
int mins = totalSecs / 60;
int secs = totalSecs % 60;
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
pixelFont.draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255});
// --- Gravity HUD: show current gravity in ms and equivalent fps (top-right) ---
{
char gms[64];
double gms_val = game.getGravityMs();
double gfps = gms_val > 0.0 ? (1000.0 / gms_val) : 0.0;
snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps);
pixelFont.draw(renderer, LOGICAL_W - 260, 10, gms, 0.9f, {200, 200, 220, 255});
}
// Hold piece (if implemented)
if (game.held().type < PIECE_COUNT) {
pixelFont.draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255});
drawSmallPiece(renderer, blocksTex, game.held().type, statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f);
}
// Pause overlay: don't draw pause UI when the exit-confirm popup is showing
if (game.isPaused() && !showExitConfirmPopup) {
// Semi-transparent overlay
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180);
SDL_FRect pauseOverlay{0, 0, LOGICAL_W, LOGICAL_H};
SDL_RenderFillRect(renderer, &pauseOverlay);
// Pause text
pixelFont.draw(renderer, LOGICAL_W * 0.5f - 80, LOGICAL_H * 0.5f - 20, "PAUSED", 2.0f, {255, 255, 255, 255});
pixelFont.draw(renderer, LOGICAL_W * 0.5f - 120, LOGICAL_H * 0.5f + 30, "Press P to resume", 0.8f, {200, 200, 220, 255});
}
// Exit confirmation popup (modal)
if (showExitConfirmPopup) {
// Compute content offsets for consistent placement across window sizes
float contentW = LOGICAL_W * logicalScale;
float contentH = LOGICAL_H * logicalScale;
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
float popupW = 420, popupH = 180;
float popupX = (LOGICAL_W - popupW) / 2;
float popupY = (LOGICAL_H - popupH) / 2;
// Dim entire window (use window coordinates so it always covers 100% of the target)
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 200);
// 1. Dim the background
SDL_SetRenderViewport(renderer, nullptr); // Use window coordinates for full screen dim
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); // Dark semi-transparent
SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &fullWin);
// Restore logical viewport for drawing content-local popup
// Restore logical viewport
SDL_SetRenderViewport(renderer, &logicalVP);
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
// Draw popup box (drawRect will apply contentOffset internally)
drawRect(popupX - 4, popupY - 4, popupW + 8, popupH + 8, {60, 70, 90, 255});
drawRect(popupX, popupY, popupW, popupH, {20, 22, 28, 240});
// 2. Calculate content offsets (same as in GameRenderer)
float contentScale = logicalScale;
float contentW = LOGICAL_W * contentScale;
float contentH = LOGICAL_H * contentScale;
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
// Center title and body text inside popup (use pixelFont for retro P2 font)
const std::string title = "Exit game?";
const std::string line1 = "Are you sure you want to";
const std::string line2 = "leave the current game?";
// 3. Draw Game Over Box
float boxW = 500.0f;
float boxH = 350.0f;
float boxX = (LOGICAL_W - boxW) * 0.5f;
float boxY = (LOGICAL_H - boxH) * 0.5f;
int wTitle=0,hTitle=0; pixelFont.measure(title, 1.6f, wTitle, hTitle);
int wL1=0,hL1=0; pixelFont.measure(line1, 0.9f, wL1, hL1);
int wL2=0,hL2=0; pixelFont.measure(line2, 0.9f, wL2, hL2);
// Draw box background
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH};
SDL_RenderFillRect(renderer, &boxRect);
// Draw box border
SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255);
SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6};
SDL_RenderFillRect(renderer, &borderRect); // Use FillRect for border background effect
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
SDL_RenderFillRect(renderer, &boxRect); // Redraw background on top of border rect
float titleX = popupX + (popupW - (float)wTitle) * 0.5f;
float l1X = popupX + (popupW - (float)wL1) * 0.5f;
float l2X = popupX + (popupW - (float)wL2) * 0.5f;
// 4. Draw Text
// 4. Draw Text
// Title
bool realHighScore = scores.isHighScore(game.score());
const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER";
int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH);
pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255});
pixelFont.draw(renderer, titleX + contentOffsetX, popupY + contentOffsetY + 20, title, 1.6f, {255, 220, 0, 255});
pixelFont.draw(renderer, l1X + contentOffsetX, popupY + contentOffsetY + 60, line1, 0.9f, SDL_Color{220,220,230,255});
pixelFont.draw(renderer, l2X + contentOffsetX, popupY + contentOffsetY + 84, line2, 0.9f, SDL_Color{220,220,230,255});
// Score
char scoreStr[64];
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game.score());
int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH);
pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255});
// Buttons (center labels inside buttons) - use pixelFont for labels
float btnW = 140, btnH = 46;
float yesX = popupX + popupW * 0.25f - btnW/2.0f;
float noX = popupX + popupW * 0.75f - btnW/2.0f;
float btnY = popupY + popupH - 60;
if (isNewHighScore) {
// Name Entry
const char* enterName = "ENTER NAME:";
int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH);
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
drawRect(yesX - 2, btnY - 2, btnW + 4, btnH + 4, {100, 120, 140, 255});
drawRect(yesX, btnY, btnW, btnH, {200, 60, 60, 255});
const std::string yes = "YES";
int wy=0,hy=0; pixelFont.measure(yes, 1.0f, wy, hy);
pixelFont.draw(renderer, yesX + (btnW - (float)wy) * 0.5f + contentOffsetX, btnY + (btnH - (float)hy) * 0.5f + contentOffsetY, yes, 1.0f, {255,255,255,255});
// Input box
float inputW = 300.0f;
float inputH = 40.0f;
float inputX = boxX + (boxW - inputW) * 0.5f;
float inputY = boxY + 200.0f;
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
SDL_RenderFillRect(renderer, &inputRect);
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
SDL_RenderRect(renderer, &inputRect);
drawRect(noX - 2, btnY - 2, btnW + 4, btnH + 4, {100, 120, 140, 255});
drawRect(noX, btnY, btnW, btnH, {80, 140, 80, 255});
const std::string no = "NO";
int wn=0,hn=0; pixelFont.measure(no, 1.0f, wn, hn);
pixelFont.draw(renderer, noX + (btnW - (float)wn) * 0.5f + contentOffsetX, btnY + (btnH - (float)hn) * 0.5f + contentOffsetY, no, 1.0f, {255,255,255,255});
// Player Name (with cursor)
std::string display = playerName;
if ((SDL_GetTicks() / 500) % 2 == 0) display += "_"; // Blink cursor
int nW=0, nH=0; pixelFont.measure(display, 1.2f, nW, nH);
pixelFont.draw(renderer, inputX + (inputW - nW) * 0.5f + contentOffsetX, inputY + (inputH - nH) * 0.5f + contentOffsetY, display, 1.2f, {255, 255, 255, 255});
// Hint
const char* hint = "PRESS ENTER TO SUBMIT";
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
} else {
// Lines
char linesStr[64];
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game.lines());
int lW=0, lH=0; pixelFont.measure(linesStr, 1.2f, lW, lH);
pixelFont.draw(renderer, boxX + (boxW - lW) * 0.5f + contentOffsetX, boxY + 140 + contentOffsetY, linesStr, 1.2f, {255, 255, 255, 255});
// Level
char levelStr[64];
snprintf(levelStr, sizeof(levelStr), "LEVEL: %d", game.level());
int lvW=0, lvH=0; pixelFont.measure(levelStr, 1.2f, lvW, lvH);
pixelFont.draw(renderer, boxX + (boxW - lvW) * 0.5f + contentOffsetX, boxY + 180 + contentOffsetY, levelStr, 1.2f, {255, 255, 255, 255});
// Instructions
const char* instr = "PRESS ENTER TO RESTART";
int iW=0, iH=0; pixelFont.measure(instr, 0.9f, iW, iH);
pixelFont.draw(renderer, boxX + (boxW - iW) * 0.5f + contentOffsetX, boxY + 260 + contentOffsetY, instr, 0.9f, {255, 220, 0, 255});
const char* instr2 = "PRESS ESC FOR MENU";
int iW2=0, iH2=0; pixelFont.measure(instr2, 0.9f, iW2, iH2);
pixelFont.draw(renderer, boxX + (boxW - iW2) * 0.5f + contentOffsetX, boxY + 290 + contentOffsetY, instr2, 0.9f, {255, 220, 0, 255});
}
}
// Controls hint at bottom
font.draw(renderer, 20, LOGICAL_H - 30, "ARROWS=Move Z/X=Rotate C=Hold SPACE=Drop P=Pause ESC=Menu", 1.0f, {150, 150, 170, 255});
}
break;
case AppState::GameOver:
font.draw(renderer, LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, SDL_Color{255, 80, 60, 255});
{
char buf[128];
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", game.score(), game.lines(), game.level());
font.draw(renderer, LOGICAL_W * 0.5f - 120, 220, buf, 1.2f, SDL_Color{220, 220, 230, 255});
}
font.draw(renderer, LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, SDL_Color{200, 200, 220, 255});
break;
}