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

@ -104,25 +104,52 @@ void Audio::feed(Uint32 bytesWanted, SDL_AudioStream* stream){
std::vector<int16_t> mix(outSamples, 0);
// 1) Mix music into buffer (if not muted)
if(!muted && current >= 0){
// 1) Mix music into buffer (if not muted)
if(!muted && playing){
size_t cursorBytes = 0;
while(cursorBytes < bytesWanted){
if(current < 0) break;
auto &trk = tracks[current];
size_t samplesAvail = trk.pcm.size() - trk.cursor; // samples (int16)
if(samplesAvail == 0){ nextTrack(); if(current < 0) break; continue; }
AudioTrack* trk = nullptr;
if (isMenuMusic) {
if (menuTrack.ok) trk = &menuTrack;
} else {
if (current >= 0 && current < (int)tracks.size()) trk = &tracks[current];
}
if (!trk) break;
size_t samplesAvail = trk->pcm.size() - trk->cursor; // samples (int16)
if(samplesAvail == 0){
if (isMenuMusic) {
trk->cursor = 0; // Loop menu music
continue;
} else {
nextTrack();
if(current < 0) break;
continue;
}
}
size_t samplesNeeded = (bytesWanted - cursorBytes) / sizeof(int16_t);
size_t toCopy = (samplesAvail < samplesNeeded) ? samplesAvail : samplesNeeded;
if(toCopy == 0) break;
// Mix add with clamp
size_t startSample = cursorBytes / sizeof(int16_t);
for(size_t i=0;i<toCopy;++i){
int v = (int)mix[startSample+i] + (int)trk.pcm[trk.cursor+i];
int v = (int)mix[startSample+i] + (int)trk->pcm[trk->cursor+i];
if(v>32767) v=32767; if(v<-32768) v=-32768; mix[startSample+i] = (int16_t)v;
}
trk.cursor += toCopy;
trk->cursor += toCopy;
cursorBytes += (Uint32)(toCopy * sizeof(int16_t));
if(trk.cursor >= trk.pcm.size()) nextTrack();
if(trk->cursor >= trk->pcm.size()) {
if (isMenuMusic) {
trk->cursor = 0; // Loop menu music
} else {
nextTrack();
}
}
}
}
@ -266,6 +293,39 @@ int Audio::getLoadedTrackCount() const {
return loadedCount;
}
void Audio::setMenuTrack(const std::string& path) {
menuTrack.path = path;
#ifdef _WIN32
// Ensure MF is started (might be redundant if init called, but safe)
if(!mfStarted){ if(FAILED(MFStartup(MF_VERSION))) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MFStartup failed"); } else mfStarted=true; }
if (decodeMP3(path, menuTrack.pcm, menuTrack.rate, menuTrack.channels)) {
menuTrack.ok = true;
} else {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode menu track %s", path.c_str());
}
#else
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported (stub): %s", path.c_str());
#endif
}
void Audio::playMenuMusic() {
isMenuMusic = true;
if (menuTrack.ok) {
menuTrack.cursor = 0;
}
start();
}
void Audio::playGameMusic() {
isMenuMusic = false;
// If we were playing menu music, we might want to pick a random track or resume
if (current < 0 && !tracks.empty()) {
nextTrack();
}
start();
}
void Audio::shutdown(){
// Stop background loading thread first
if (loadingThread.joinable()) {

View File

@ -43,6 +43,12 @@ public:
void shuffle(); // randomize order
void start(); // begin playback
void toggleMute();
// Menu music support
void setMenuTrack(const std::string& path);
void playMenuMusic();
void playGameMusic();
// Queue a sound effect to mix over the music (pcm can be mono/stereo, any rate; will be converted)
void playSfx(const std::vector<int16_t>& pcm, int channels, int rate, float volume);
void shutdown();
@ -54,7 +60,10 @@ private:
bool ensureStream();
void backgroundLoadingThread(); // background thread function
std::vector<AudioTrack> tracks; int current=-1; bool playing=false; bool muted=false; std::mt19937 rng{std::random_device{}()};
std::vector<AudioTrack> tracks;
AudioTrack menuTrack;
bool isMenuMusic = false;
int current=-1; bool playing=false; bool muted=false; std::mt19937 rng{std::random_device{}()};
SDL_AudioStream* audioStream=nullptr; SDL_AudioSpec outSpec{}; int outChannels=2; int outRate=44100; bool mfStarted=false;
// Threading support

View File

@ -14,19 +14,19 @@ double GravityManager::getGlobalMultiplier() const { return globalMultiplier; }
void GravityManager::setLevelMultiplier(int level, double m) {
if (level < 0) return;
int idx = level >= 29 ? 29 : level;
int idx = level >= 19 ? 19 : level;
levelMultipliers[idx] = std::clamp(m, 0.01, 100.0);
}
double GravityManager::getLevelMultiplier(int level) const {
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
int idx = level < 0 ? 0 : (level >= 19 ? 19 : level);
return levelMultipliers[idx];
}
double GravityManager::getMsForLevel(int level) const {
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
double frames = static_cast<double>(FRAMES_TABLE[idx]) * levelMultipliers[idx];
double result = frames * FRAME_MS * globalMultiplier;
int idx = level < 0 ? 0 : (level >= 19 ? 19 : level);
double baseMs = LEVEL_SPEEDS_MS[idx];
double result = baseMs * levelMultipliers[idx] * globalMultiplier;
return std::max(1.0, result);
}

View File

@ -18,14 +18,11 @@ public:
double getFpsForLevel(int level) const;
private:
static constexpr double NES_FPS = 60.0988;
static constexpr double FRAME_MS = 1000.0 / NES_FPS;
static constexpr int FRAMES_TABLE[30] = {
48,43,38,33,28,23,18,13,8,6,
5,5,5,4,4,4,3,3,3,2,
2,2,2,2,2,2,2,2,2,1
static constexpr double LEVEL_SPEEDS_MS[20] = {
1000.0, 920.0, 840.0, 760.0, 680.0, 600.0, 520.0, 440.0, 360.0, 280.0,
200.0, 160.0, 160.0, 120.0, 120.0, 100.0, 100.0, 80.0, 80.0, 60.0
};
double globalMultiplier{1.0};
std::array<double,30> levelMultipliers{}; // default 1.0
std::array<double,20> levelMultipliers{}; // default 1.0
};

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;
}

View File

@ -1,9 +1,19 @@
// Scores.cpp - Implementation of ScoreManager (copied into src/persistence)
// Scores.cpp - Implementation of ScoreManager with Firebase Sync
#include "Scores.h"
#include <SDL3/SDL.h>
#include <fstream>
#include <sstream>
#include <algorithm>
#include <cpr/cpr.h>
#include <nlohmann/json.hpp>
#include <iostream>
#include <thread>
#include <ctime>
using json = nlohmann::json;
// Firebase Realtime Database URL
const std::string FIREBASE_URL = "https://tetris-90139.firebaseio.com/scores.json";
ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {}
@ -16,6 +26,52 @@ std::string ScoreManager::filePath() const {
void ScoreManager::load() {
scores.clear();
// Try to load from Firebase first
try {
cpr::Response r = cpr::Get(cpr::Url{FIREBASE_URL}, cpr::Timeout{2000}); // 2s timeout
if (r.status_code == 200 && !r.text.empty() && r.text != "null") {
auto j = json::parse(r.text);
// Firebase returns a map of auto-generated IDs to objects
if (j.is_object()) {
for (auto& [key, value] : j.items()) {
ScoreEntry e;
if (value.contains("score")) e.score = value["score"];
if (value.contains("lines")) e.lines = value["lines"];
if (value.contains("level")) e.level = value["level"];
if (value.contains("timeSec")) e.timeSec = value["timeSec"];
if (value.contains("name")) e.name = value["name"];
scores.push_back(e);
}
}
// Or it might be an array if keys are integers (unlikely for Firebase push)
else if (j.is_array()) {
for (auto& value : j) {
ScoreEntry e;
if (value.contains("score")) e.score = value["score"];
if (value.contains("lines")) e.lines = value["lines"];
if (value.contains("level")) e.level = value["level"];
if (value.contains("timeSec")) e.timeSec = value["timeSec"];
if (value.contains("name")) e.name = value["name"];
scores.push_back(e);
}
}
// Sort and keep top scores
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
if (scores.size() > maxEntries) scores.resize(maxEntries);
// Save to local cache
save();
return;
}
} catch (...) {
// Ignore network errors and fall back to local file
std::cerr << "Failed to load from Firebase, falling back to local file." << std::endl;
}
// Fallback to local file
std::ifstream f(filePath());
if (!f) {
// Create sample high scores if file doesn't exist
@ -56,11 +112,43 @@ void ScoreManager::save() const {
}
}
void ScoreManager::submit(int score, int lines, int level, double timeSec) {
scores.push_back(ScoreEntry{score,lines,level,timeSec});
void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name) {
// Add to local list
scores.push_back(ScoreEntry{score,lines,level,timeSec, name});
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
if (scores.size()>maxEntries) scores.resize(maxEntries);
save();
// Submit to Firebase
// Run in a detached thread to avoid blocking the UI?
// For simplicity, we'll do it blocking for now, or rely on short timeout.
// Ideally this should be async.
json j;
j["score"] = score;
j["lines"] = lines;
j["level"] = level;
j["timeSec"] = timeSec;
j["name"] = name;
j["timestamp"] = std::time(nullptr); // Add timestamp
// Fire and forget (async) would be better, but for now let's just try to send
// We can use std::thread to make it async
std::thread([j]() {
try {
cpr::Post(cpr::Url{FIREBASE_URL},
cpr::Body{j.dump()},
cpr::Header{{"Content-Type", "application/json"}},
cpr::Timeout{5000});
} catch (...) {
// Ignore errors
}
}).detach();
}
bool ScoreManager::isHighScore(int score) const {
if (scores.size() < maxEntries) return true;
return score > scores.back().score;
}
void ScoreManager::createSampleScores() {

View File

@ -10,7 +10,8 @@ public:
explicit ScoreManager(size_t maxScores = 12);
void load();
void save() const;
void submit(int score, int lines, int level, double timeSec);
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER");
bool isHighScore(int score) const;
const std::vector<ScoreEntry>& all() const { return scores; }
private:
std::vector<ScoreEntry> scores;