diff --git a/CMakeLists.txt b/CMakeLists.txt index 34ee9ca..af8d82e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ set(TETRIS_SOURCES src/core/Settings.cpp src/graphics/renderers/RenderManager.cpp src/persistence/Scores.cpp + src/network/supabase_client.cpp src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield3D.cpp src/graphics/effects/SpaceWarp.cpp diff --git a/src/network/supabase_client.cpp b/src/network/supabase_client.cpp new file mode 100644 index 0000000..1b43630 --- /dev/null +++ b/src/network/supabase_client.cpp @@ -0,0 +1,168 @@ +#include "supabase_client.h" +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { +// Supabase constants (publishable anon key) +const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co"; +const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA"; + +std::string buildUrl(const std::string &path) { + std::string url = SUPABASE_URL; + if (!url.empty() && url.back() == '/') url.pop_back(); + url += "/rest/v1/" + path; + return url; +} + +size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t realSize = size * nmemb; + std::string *s = reinterpret_cast(userp); + s->append(reinterpret_cast(contents), realSize); + return realSize; +} + +struct CurlInit { + CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); } + ~CurlInit() { curl_global_cleanup(); } +}; +static CurlInit g_curl_init; +} + +namespace supabase { + +void SubmitHighscoreAsync(const ScoreEntry &entry) { + std::thread([entry]() { + try { + CURL* curl = curl_easy_init(); + if (!curl) return; + + std::string url = buildUrl("highscores"); + + json j; + j["score"] = entry.score; + j["lines"] = entry.lines; + j["level"] = entry.level; + j["time_sec"] = static_cast(std::lround(entry.timeSec)); + j["name"] = entry.name; + j["game_type"] = entry.gameType; + j["timestamp"] = static_cast(std::time(nullptr)); + + std::string body = j.dump(); + struct curl_slist *headers = nullptr; + std::string h1 = std::string("apikey: ") + SUPABASE_ANON_KEY; + std::string h2 = std::string("Authorization: Bearer ") + SUPABASE_ANON_KEY; + headers = curl_slist_append(headers, h1.c_str()); + headers = curl_slist_append(headers, h2.c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + std::string resp; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + + // Debug: print outgoing request + std::cerr << "[Supabase] POST " << url << "\n"; + std::cerr << "[Supabase] Body: " << body << "\n"; + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n"; + } else { + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + std::cerr << "[Supabase] POST response code: " << http_code << " body_len=" << resp.size() << "\n"; + if (!resp.empty()) std::cerr << "[Supabase] POST response: " << resp << "\n"; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } catch (...) { + // swallow errors + } + }).detach(); +} + +std::vector FetchHighscores(const std::string &gameType, int limit) { + std::vector out; + try { + CURL* curl = curl_easy_init(); + if (!curl) return out; + + std::string path = "highscores"; + std::string query; + if (!gameType.empty()) { + if (gameType == "challenge") { + query = "?game_type=eq." + gameType + "&order=level.desc,time_sec.asc&limit=" + std::to_string(limit); + } else { + query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(limit); + } + } else { + query = "?order=score.desc&limit=" + std::to_string(limit); + } + + std::string url = buildUrl(path) + query; + + struct curl_slist *headers = nullptr; + headers = curl_slist_append(headers, ("apikey: " + SUPABASE_ANON_KEY).c_str()); + headers = curl_slist_append(headers, ("Authorization: Bearer " + SUPABASE_ANON_KEY).c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + + std::string resp; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + + // Debug: print outgoing GET + std::cerr << "[Supabase] GET " << url << "\n"; + + CURLcode res = curl_easy_perform(curl); + if (res == CURLE_OK) { + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + std::cerr << "[Supabase] GET response code: " << http_code << " body_len=" << resp.size() << "\n"; + if (!resp.empty()) std::cerr << "[Supabase] GET response: " << resp << "\n"; + try { + auto j = json::parse(resp); + if (j.is_array()) { + for (auto &v : j) { + ScoreEntry e{}; + if (v.contains("score")) e.score = v["score"].get(); + if (v.contains("lines")) e.lines = v["lines"].get(); + if (v.contains("level")) e.level = v["level"].get(); + if (v.contains("time_sec")) { + try { e.timeSec = v["time_sec"].get(); } catch(...) { e.timeSec = v["time_sec"].get(); } + } else if (v.contains("timestamp")) { + e.timeSec = v["timestamp"].get(); + } + if (v.contains("name")) e.name = v["name"].get(); + if (v.contains("game_type")) e.gameType = v["game_type"].get(); + out.push_back(e); + } + } + } catch (...) { + std::cerr << "[Supabase] GET parse error" << std::endl; + } + } else { + std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n"; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } catch (...) { + // swallow + } + return out; +} + +} // namespace supabase diff --git a/src/network/supabase_client.h b/src/network/supabase_client.h new file mode 100644 index 0000000..82e9fde --- /dev/null +++ b/src/network/supabase_client.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include +#include "../persistence/Scores.h" + +namespace supabase { + +// Submit a highscore asynchronously (detached thread) +void SubmitHighscoreAsync(const ScoreEntry &entry); + +// Fetch highscores for a game type. If gameType is empty, fetch all (limited). +std::vector FetchHighscores(const std::string &gameType, int limit); + +} // namespace supabase diff --git a/src/persistence/Scores.cpp b/src/persistence/Scores.cpp index 3232ad4..b92178e 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -1,20 +1,18 @@ -// Scores.cpp - Implementation of ScoreManager with Firebase Sync +// Scores.cpp - Implementation of ScoreManager #include "Scores.h" #include #include #include #include -#include +#include "../network/supabase_client.h" #include #include #include #include +#include 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) {} std::string ScoreManager::filePath() const { @@ -27,50 +25,18 @@ std::string ScoreManager::filePath() const { void ScoreManager::load() { scores.clear(); - // Try to load from Firebase first + // Try to load from Supabase 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"]; - if (value.contains("game_type")) e.gameType = value["game_type"].get(); - 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"]; - if (value.contains("game_type")) e.gameType = value["game_type"].get(); - scores.push_back(e); - } - } - - // Sort and keep top scores + auto fetched = supabase::FetchHighscores("", static_cast(maxEntries)); + if (!fetched.empty()) { + scores = fetched; 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; + std::cerr << "Failed to load from Supabase, falling back to local file." << std::endl; } // Fallback to local file @@ -119,37 +85,19 @@ void ScoreManager::save() const { void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name, const std::string& gameType) { // Add to local list - scores.push_back(ScoreEntry{score,lines,level,timeSec, name}); + ScoreEntry newEntry{}; + newEntry.score = score; + newEntry.lines = lines; + newEntry.level = level; + newEntry.timeSec = timeSec; + newEntry.name = name; + scores.push_back(newEntry); 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 - j["game_type"] = gameType; - - // 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(); + // Submit to Supabase asynchronously + ScoreEntry se{score, lines, level, timeSec, name, gameType}; + supabase::SubmitHighscoreAsync(se); } bool ScoreManager::isHighScore(int score) const { @@ -159,17 +107,17 @@ bool ScoreManager::isHighScore(int score) const { void ScoreManager::createSampleScores() { scores = { - {159840, 189, 14, 972, "GREGOR"}, - {156340, 132, 12, 714, "GREGOR"}, - {155219, 125, 12, 696, "GREGOR"}, - {141823, 123, 10, 710, "GREGOR"}, - {140079, 71, 11, 410, "GREGOR"}, - {116012, 121, 10, 619, "GREGOR"}, - {112643, 137, 13, 689, "GREGOR"}, - {99190, 61, 10, 378, "GREGOR"}, - {93648, 107, 10, 629, "GREGOR"}, - {89041, 115, 10, 618, "GREGOR"}, - {88600, 55, 9, 354, "GREGOR"}, - {86346, 141, 13, 723, "GREGOR"} + {159840, 189, 14, 972.0, "GREGOR"}, + {156340, 132, 12, 714.0, "GREGOR"}, + {155219, 125, 12, 696.0, "GREGOR"}, + {141823, 123, 10, 710.0, "GREGOR"}, + {140079, 71, 11, 410.0, "GREGOR"}, + {116012, 121, 10, 619.0, "GREGOR"}, + {112643, 137, 13, 689.0, "GREGOR"}, + {99190, 61, 10, 378.0, "GREGOR"}, + {93648, 107, 10, 629.0, "GREGOR"}, + {89041, 115, 10, 618.0, "GREGOR"}, + {88600, 55, 9, 354.0, "GREGOR"}, + {86346, 141, 13, 723.0, "GREGOR"} }; } diff --git a/src/persistence/Scores.h b/src/persistence/Scores.h index 1daa7cc..e08bfee 100644 --- a/src/persistence/Scores.h +++ b/src/persistence/Scores.h @@ -10,7 +10,7 @@ public: explicit ScoreManager(size_t maxScores = 12); void load(); void save() const; - // New optional `gameType` parameter will be sent to Firebase as `game_type`. + // New optional `gameType` parameter will be sent as `game_type`. // Allowed values: "classic", "versus", "cooperate", "challenge". void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER", const std::string& gameType = "classic"); bool isHighScore(int score) const; diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 1a96e49..7ba7338 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -813,11 +813,16 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi } static const std::vector EMPTY_SCORES; const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES; - // Filter highscores to show only classic gameplay entries on the main menu + // Choose which game_type to show based on current menu selection + std::string wantedType = "classic"; + if (selectedButton == 0) wantedType = "classic"; // Play / Endless + else if (selectedButton == 1) wantedType = "cooperate"; // Coop + else if (selectedButton == 2) wantedType = "challenge"; // Challenge + // Filter highscores to the desired game type std::vector filtered; filtered.reserve(hs.size()); for (const auto &e : hs) { - if (e.gameType == "classic") filtered.push_back(e); + if (e.gameType == wantedType) filtered.push_back(e); } size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10