fixed highscores
This commit is contained in:
@ -899,7 +899,7 @@ void TetrisApp::Impl::runLoop()
|
|||||||
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||||
int combinedScore = leftScore + rightScore;
|
int combinedScore = leftScore + rightScore;
|
||||||
ensureScoresLoaded();
|
ensureScoresLoaded();
|
||||||
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined);
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
|
||||||
Settings::instance().setPlayerName(playerName);
|
Settings::instance().setPlayerName(playerName);
|
||||||
isNewHighScore = false;
|
isNewHighScore = false;
|
||||||
SDL_StopTextInput(window);
|
SDL_StopTextInput(window);
|
||||||
@ -911,7 +911,8 @@ void TetrisApp::Impl::runLoop()
|
|||||||
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
||||||
if (playerName.empty()) playerName = "PLAYER";
|
if (playerName.empty()) playerName = "PLAYER";
|
||||||
ensureScoresLoaded();
|
ensureScoresLoaded();
|
||||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName);
|
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||||
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName, gt);
|
||||||
Settings::instance().setPlayerName(playerName);
|
Settings::instance().setPlayerName(playerName);
|
||||||
isNewHighScore = false;
|
isNewHighScore = false;
|
||||||
SDL_StopTextInput(window);
|
SDL_StopTextInput(window);
|
||||||
@ -1302,7 +1303,7 @@ void TetrisApp::Impl::runLoop()
|
|||||||
} else {
|
} else {
|
||||||
isNewHighScore = false;
|
isNewHighScore = false;
|
||||||
ensureScoresLoaded();
|
ensureScoresLoaded();
|
||||||
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed());
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate");
|
||||||
}
|
}
|
||||||
state = AppState::GameOver;
|
state = AppState::GameOver;
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
@ -1328,7 +1329,10 @@ void TetrisApp::Impl::runLoop()
|
|||||||
} else {
|
} else {
|
||||||
isNewHighScore = false;
|
isNewHighScore = false;
|
||||||
ensureScoresLoaded();
|
ensureScoresLoaded();
|
||||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed());
|
{
|
||||||
|
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||||
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state = AppState::GameOver;
|
state = AppState::GameOver;
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
|
|||||||
@ -1406,11 +1406,14 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
if (m_stateContext.game->isGameOver()) {
|
if (m_stateContext.game->isGameOver()) {
|
||||||
// Submit score before transitioning
|
// Submit score before transitioning
|
||||||
if (m_stateContext.scores) {
|
if (m_stateContext.scores) {
|
||||||
|
std::string gt = (m_stateContext.game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||||
m_stateContext.scores->submit(
|
m_stateContext.scores->submit(
|
||||||
m_stateContext.game->score(),
|
m_stateContext.game->score(),
|
||||||
m_stateContext.game->lines(),
|
m_stateContext.game->lines(),
|
||||||
m_stateContext.game->level(),
|
m_stateContext.game->level(),
|
||||||
m_stateContext.game->elapsed()
|
m_stateContext.game->elapsed(),
|
||||||
|
std::string("PLAYER"),
|
||||||
|
gt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
m_stateManager->setState(AppState::GameOver);
|
m_stateManager->setState(AppState::GameOver);
|
||||||
|
|||||||
@ -54,14 +54,33 @@ void ScoreManager::load() {
|
|||||||
ScoreEntry e;
|
ScoreEntry e;
|
||||||
iss >> e.score >> e.lines >> e.level >> e.timeSec;
|
iss >> e.score >> e.lines >> e.level >> e.timeSec;
|
||||||
if (iss) {
|
if (iss) {
|
||||||
// Try to read name (rest of line after timeSec)
|
// Try to read name (rest of line after timeSec). We may also have a trailing gameType token.
|
||||||
std::string remaining;
|
std::string remaining;
|
||||||
std::getline(iss, remaining);
|
std::getline(iss, remaining);
|
||||||
if (!remaining.empty() && remaining[0] == ' ') {
|
if (!remaining.empty() && remaining[0] == ' ') remaining = remaining.substr(1);
|
||||||
e.name = remaining.substr(1); // Remove leading space
|
if (!remaining.empty()) {
|
||||||
|
static const std::vector<std::string> known = {"classic","cooperate","challenge","versus"};
|
||||||
|
while (!remaining.empty() && (remaining.back() == '\n' || remaining.back() == '\r' || remaining.back() == ' ')) remaining.pop_back();
|
||||||
|
size_t lastSpace = remaining.find_last_of(' ');
|
||||||
|
std::string lastToken = (lastSpace == std::string::npos) ? remaining : remaining.substr(lastSpace + 1);
|
||||||
|
bool matched = false;
|
||||||
|
for (const auto &k : known) {
|
||||||
|
if (lastToken == k) {
|
||||||
|
matched = true;
|
||||||
|
e.gameType = k;
|
||||||
|
if (lastSpace == std::string::npos) e.name = "PLAYER";
|
||||||
|
else e.name = remaining.substr(0, lastSpace);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
e.name = remaining;
|
||||||
|
e.gameType = "classic";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.name = "PLAYER";
|
||||||
|
e.gameType = "classic";
|
||||||
}
|
}
|
||||||
// For backward compatibility local files may not include gameType; default is 'classic'
|
|
||||||
e.gameType = "classic";
|
|
||||||
scores.push_back(e);
|
scores.push_back(e);
|
||||||
}
|
}
|
||||||
if (scores.size() >= maxEntries) break;
|
if (scores.size() >= maxEntries) break;
|
||||||
@ -91,6 +110,8 @@ void ScoreManager::submit(int score, int lines, int level, double timeSec, const
|
|||||||
newEntry.level = level;
|
newEntry.level = level;
|
||||||
newEntry.timeSec = timeSec;
|
newEntry.timeSec = timeSec;
|
||||||
newEntry.name = name;
|
newEntry.name = name;
|
||||||
|
// preserve the game type locally so menu filtering works immediately
|
||||||
|
newEntry.gameType = gameType;
|
||||||
scores.push_back(newEntry);
|
scores.push_back(newEntry);
|
||||||
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||||
if (scores.size()>maxEntries) scores.resize(maxEntries);
|
if (scores.size()>maxEntries) scores.resize(maxEntries);
|
||||||
@ -105,6 +126,15 @@ bool ScoreManager::isHighScore(int score) const {
|
|||||||
return score > scores.back().score;
|
return score > scores.back().score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScoreManager::replaceAll(const std::vector<ScoreEntry>& newScores) {
|
||||||
|
scores = newScores;
|
||||||
|
// Ensure ordering and trimming to our configured maxEntries
|
||||||
|
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||||
|
if (scores.size() > maxEntries) scores.resize(maxEntries);
|
||||||
|
// Persist new set to local file for next launch
|
||||||
|
try { save(); } catch (...) { /* swallow */ }
|
||||||
|
}
|
||||||
|
|
||||||
void ScoreManager::createSampleScores() {
|
void ScoreManager::createSampleScores() {
|
||||||
scores = {
|
scores = {
|
||||||
{159840, 189, 14, 972.0, "GREGOR"},
|
{159840, 189, 14, 972.0, "GREGOR"},
|
||||||
|
|||||||
@ -10,6 +10,8 @@ public:
|
|||||||
explicit ScoreManager(size_t maxScores = 12);
|
explicit ScoreManager(size_t maxScores = 12);
|
||||||
void load();
|
void load();
|
||||||
void save() const;
|
void save() const;
|
||||||
|
// Replace the in-memory scores (thread-safe caller should ensure non-blocking)
|
||||||
|
void replaceAll(const std::vector<ScoreEntry>& newScores);
|
||||||
// New optional `gameType` parameter will be sent as `game_type`.
|
// New optional `gameType` parameter will be sent as `game_type`.
|
||||||
// Allowed values: "classic", "versus", "cooperate", "challenge".
|
// 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");
|
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER", const std::string& gameType = "classic");
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#include "MenuState.h"
|
#include "MenuState.h"
|
||||||
#include "persistence/Scores.h"
|
#include "persistence/Scores.h"
|
||||||
|
#include "../network/supabase_client.h"
|
||||||
#include "graphics/Font.h"
|
#include "graphics/Font.h"
|
||||||
#include "../graphics/ui/HelpOverlay.h"
|
#include "../graphics/ui/HelpOverlay.h"
|
||||||
#include "../core/GlobalState.h"
|
#include "../core/GlobalState.h"
|
||||||
@ -169,6 +170,24 @@ void MenuState::onEnter() {
|
|||||||
if (ctx.exitPopupSelectedButton) {
|
if (ctx.exitPopupSelectedButton) {
|
||||||
*ctx.exitPopupSelectedButton = 1;
|
*ctx.exitPopupSelectedButton = 1;
|
||||||
}
|
}
|
||||||
|
// Refresh highscores for classic/cooperate/challenge asynchronously
|
||||||
|
try {
|
||||||
|
std::thread([this]() {
|
||||||
|
try {
|
||||||
|
auto c_classic = supabase::FetchHighscores("classic", 12);
|
||||||
|
auto c_coop = supabase::FetchHighscores("cooperate", 12);
|
||||||
|
auto c_challenge = supabase::FetchHighscores("challenge", 12);
|
||||||
|
std::vector<ScoreEntry> combined;
|
||||||
|
combined.reserve(c_classic.size() + c_coop.size() + c_challenge.size());
|
||||||
|
combined.insert(combined.end(), c_classic.begin(), c_classic.end());
|
||||||
|
combined.insert(combined.end(), c_coop.begin(), c_coop.end());
|
||||||
|
combined.insert(combined.end(), c_challenge.begin(), c_challenge.end());
|
||||||
|
if (this->ctx.scores) this->ctx.scores->replaceAll(combined);
|
||||||
|
} catch (...) {
|
||||||
|
// swallow network errors - keep existing scores
|
||||||
|
}
|
||||||
|
}).detach();
|
||||||
|
} catch (...) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||||
|
|||||||
213
supabe_integrate.md
Normal file
213
supabe_integrate.md
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# Spacetris — Supabase Highscore Integration
|
||||||
|
## VS Code Copilot AI Agent Prompt
|
||||||
|
|
||||||
|
You are integrating Supabase highscores into a native C++ SDL3 game called **Spacetris**.
|
||||||
|
|
||||||
|
This is a REST-only integration using Supabase PostgREST.
|
||||||
|
Do NOT use any Supabase JS SDKs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Implement a highscore backend using Supabase for these game modes:
|
||||||
|
- classic
|
||||||
|
- challenge
|
||||||
|
- cooperate
|
||||||
|
- versus
|
||||||
|
|
||||||
|
Highscores must be:
|
||||||
|
- Submitted asynchronously on game over
|
||||||
|
- Fetched asynchronously for leaderboard screens
|
||||||
|
- Non-blocking (never stall render loop)
|
||||||
|
- Offline-safe (fail silently)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Supabase Configuration
|
||||||
|
|
||||||
|
The following constants are provided at build time:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co";
|
||||||
|
const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA";
|
||||||
|
````
|
||||||
|
|
||||||
|
All requests go to:
|
||||||
|
|
||||||
|
```
|
||||||
|
{SUPABASE_URL}/rest/v1/highscores
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database Schema (Already Exists)
|
||||||
|
|
||||||
|
The Supabase table `highscores` has the following fields:
|
||||||
|
|
||||||
|
* score (integer)
|
||||||
|
* lines (integer)
|
||||||
|
* level (integer)
|
||||||
|
* time_sec (integer)
|
||||||
|
* name (string)
|
||||||
|
* game_type ("classic", "versus", "cooperate", "challenge")
|
||||||
|
* timestamp (integer, UNIX epoch seconds)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Model in C++
|
||||||
|
|
||||||
|
Create a struct matching the database schema:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct HighscoreEntry {
|
||||||
|
int score;
|
||||||
|
int lines;
|
||||||
|
int level;
|
||||||
|
int timeSec;
|
||||||
|
std::string name;
|
||||||
|
std::string gameType;
|
||||||
|
int timestamp;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. HTTP Layer Requirements
|
||||||
|
|
||||||
|
* Use **libcurl**
|
||||||
|
* Use **JSON** (nlohmann::json or equivalent)
|
||||||
|
* All network calls must run in a worker thread
|
||||||
|
* Never block the SDL main loop
|
||||||
|
|
||||||
|
Required HTTP headers:
|
||||||
|
|
||||||
|
```
|
||||||
|
apikey: SUPABASE_ANON_KEY
|
||||||
|
Authorization: Bearer SUPABASE_ANON_KEY
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Submit Highscore (POST)
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void SubmitHighscoreAsync(const HighscoreEntry& entry);
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Convert entry to JSON
|
||||||
|
* POST to `/rest/v1/highscores`
|
||||||
|
* On failure:
|
||||||
|
|
||||||
|
* Log error
|
||||||
|
* Do NOT crash
|
||||||
|
* Optionally store JSON locally for retry
|
||||||
|
|
||||||
|
Example JSON payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"score": 123456,
|
||||||
|
"lines": 240,
|
||||||
|
"level": 37,
|
||||||
|
"time_sec": 1820,
|
||||||
|
"name": "P1 & P2",
|
||||||
|
"game_type": "cooperate",
|
||||||
|
"timestamp": 1710000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Fetch Leaderboard (GET)
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<HighscoreEntry> FetchHighscores(
|
||||||
|
const std::string& gameType,
|
||||||
|
int limit
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
REST query examples:
|
||||||
|
|
||||||
|
Classic:
|
||||||
|
|
||||||
|
```
|
||||||
|
?game_type=eq.classic&order=score.desc&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Challenge:
|
||||||
|
|
||||||
|
```
|
||||||
|
?game_type=eq.challenge&order=level.desc,time_sec.asc&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Cooperate:
|
||||||
|
|
||||||
|
```
|
||||||
|
?game_type=eq.cooperate&order=score.desc&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Threading Model
|
||||||
|
|
||||||
|
* Use `std::thread` or a simple job queue
|
||||||
|
* Network calls must not run on the render thread
|
||||||
|
* Use mutex or lock-free queue to pass results back to UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Error Handling Rules
|
||||||
|
|
||||||
|
* If Supabase is unreachable:
|
||||||
|
|
||||||
|
* Game continues normally
|
||||||
|
* Leaderboard screen shows "Offline"
|
||||||
|
* Never block gameplay
|
||||||
|
* Never show raw network errors to player
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Security Constraints
|
||||||
|
|
||||||
|
* API key is public (acceptable for highscores)
|
||||||
|
* Obfuscate key in binary if possible
|
||||||
|
* Do NOT trust client-side data (future server validation planned)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. File Structure Suggestion
|
||||||
|
|
||||||
|
```
|
||||||
|
/network
|
||||||
|
supabase_client.h
|
||||||
|
supabase_client.cpp
|
||||||
|
|
||||||
|
/highscores
|
||||||
|
highscore_submit.cpp
|
||||||
|
highscore_fetch.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Acceptance Criteria
|
||||||
|
|
||||||
|
* Highscores are submitted after game over
|
||||||
|
* Leaderboards load without blocking gameplay
|
||||||
|
* Works for all four game types
|
||||||
|
* Offline mode does not crash or freeze
|
||||||
|
* Code is clean, modular, and SDL3-safe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Summary for the Agent
|
||||||
|
|
||||||
|
Integrate Supabase highscores into Spacetris using REST calls from C++ with libcurl. Use async submission and fetching. Support classic, challenge, cooperate, and versus modes. Ensure non-blocking behavior and graceful offline handling.
|
||||||
Reference in New Issue
Block a user