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:
@ -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()) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
591
src/main.cpp
591
src/main.cpp
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user