Merge branch 'feature/CooperativeMode' into develop
This commit is contained in:
@ -33,6 +33,7 @@ set(TETRIS_SOURCES
|
||||
src/main.cpp
|
||||
src/app/TetrisApp.cpp
|
||||
src/gameplay/core/Game.cpp
|
||||
src/gameplay/coop/CoopGame.cpp
|
||||
src/core/GravityManager.cpp
|
||||
src/core/state/StateManager.cpp
|
||||
# New core architecture classes
|
||||
@ -43,12 +44,14 @@ 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
|
||||
src/graphics/ui/Font.cpp
|
||||
src/graphics/ui/HelpOverlay.cpp
|
||||
src/graphics/renderers/GameRenderer.cpp
|
||||
src/graphics/renderers/SyncLineRenderer.cpp
|
||||
src/graphics/renderers/UIRenderer.cpp
|
||||
src/audio/Audio.cpp
|
||||
src/gameplay/effects/LineEffect.cpp
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
Fullscreen=1
|
||||
|
||||
[Audio]
|
||||
Music=1
|
||||
Music=0
|
||||
Sound=1
|
||||
|
||||
[Gameplay]
|
||||
|
||||
@ -144,4 +144,7 @@ void draw(SDL_Renderer* renderer, SDL_Texture*) {
|
||||
|
||||
double getLogoAnimCounter() { return logoAnimCounter; }
|
||||
int getHoveredButton() { return hoveredButton; }
|
||||
void spawn(float x, float y) {
|
||||
fireworks.emplace_back(x, y);
|
||||
}
|
||||
} // namespace AppFireworks
|
||||
|
||||
@ -6,4 +6,5 @@ namespace AppFireworks {
|
||||
void update(double frameMs);
|
||||
double getLogoAnimCounter();
|
||||
int getHoveredButton();
|
||||
void spawn(float x, float y);
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
#include "core/state/StateManager.h"
|
||||
|
||||
#include "gameplay/core/Game.h"
|
||||
#include "gameplay/coop/CoopGame.h"
|
||||
#include "gameplay/effects/LineEffect.h"
|
||||
|
||||
#include "graphics/effects/SpaceWarp.h"
|
||||
@ -171,6 +172,8 @@ struct TetrisApp::Impl {
|
||||
int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
|
||||
bool isNewHighScore = false;
|
||||
std::string playerName;
|
||||
std::string player2Name;
|
||||
int highScoreEntryIndex = 0; // 0 = entering player1, 1 = entering player2
|
||||
bool helpOverlayPausedGame = false;
|
||||
|
||||
SDL_Window* window = nullptr;
|
||||
@ -228,6 +231,7 @@ struct TetrisApp::Impl {
|
||||
std::atomic<size_t> loadingStep{0};
|
||||
|
||||
std::unique_ptr<Game> game;
|
||||
std::unique_ptr<CoopGame> coopGame;
|
||||
std::vector<std::string> singleSounds;
|
||||
std::vector<std::string> doubleSounds;
|
||||
std::vector<std::string> tripleSounds;
|
||||
@ -242,7 +246,13 @@ struct TetrisApp::Impl {
|
||||
bool isFullscreen = false;
|
||||
bool leftHeld = false;
|
||||
bool rightHeld = false;
|
||||
bool p1LeftHeld = false;
|
||||
bool p1RightHeld = false;
|
||||
bool p2LeftHeld = false;
|
||||
bool p2RightHeld = false;
|
||||
double moveTimerMs = 0.0;
|
||||
double p1MoveTimerMs = 0.0;
|
||||
double p2MoveTimerMs = 0.0;
|
||||
double DAS = 170.0;
|
||||
double ARR = 40.0;
|
||||
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
||||
@ -421,6 +431,8 @@ int TetrisApp::Impl::init()
|
||||
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
||||
game->reset(startLevelSelection);
|
||||
|
||||
coopGame = std::make_unique<CoopGame>(startLevelSelection);
|
||||
|
||||
// Define voice line banks for gameplay callbacks
|
||||
singleSounds = {"well_played", "smooth_clear", "great_move"};
|
||||
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
||||
@ -458,6 +470,20 @@ int TetrisApp::Impl::init()
|
||||
suppressLineVoiceForLevelUp = false;
|
||||
});
|
||||
|
||||
// Keep co-op line-clear SFX behavior identical to classic.
|
||||
coopGame->setSoundCallback([this, playVoiceCue](int linesCleared) {
|
||||
if (linesCleared <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||
|
||||
if (!suppressLineVoiceForLevelUp) {
|
||||
playVoiceCue(linesCleared);
|
||||
}
|
||||
suppressLineVoiceForLevelUp = false;
|
||||
});
|
||||
|
||||
game->setLevelUpCallback([this](int /*newLevel*/) {
|
||||
if (skipNextLevelUpJingle) {
|
||||
skipNextLevelUpJingle = false;
|
||||
@ -468,6 +494,17 @@ int TetrisApp::Impl::init()
|
||||
suppressLineVoiceForLevelUp = true;
|
||||
});
|
||||
|
||||
// Mirror single-player level-up audio/visual behavior for Coop sessions
|
||||
coopGame->setLevelUpCallback([this](int /*newLevel*/) {
|
||||
if (skipNextLevelUpJingle) {
|
||||
skipNextLevelUpJingle = false;
|
||||
} else {
|
||||
SoundEffectManager::instance().playSound("new_level", 1.0f);
|
||||
SoundEffectManager::instance().playSound("lets_go", 1.0f);
|
||||
}
|
||||
suppressLineVoiceForLevelUp = true;
|
||||
});
|
||||
|
||||
game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) {
|
||||
SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f);
|
||||
});
|
||||
@ -479,7 +516,10 @@ int TetrisApp::Impl::init()
|
||||
isFullscreen = Settings::instance().isFullscreen();
|
||||
leftHeld = false;
|
||||
rightHeld = false;
|
||||
p1LeftHeld = p1RightHeld = p2LeftHeld = p2RightHeld = false;
|
||||
moveTimerMs = 0;
|
||||
p1MoveTimerMs = 0.0;
|
||||
p2MoveTimerMs = 0.0;
|
||||
DAS = 170.0;
|
||||
ARR = 40.0;
|
||||
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
|
||||
@ -506,6 +546,7 @@ int TetrisApp::Impl::init()
|
||||
ctx = StateContext{};
|
||||
ctx.stateManager = stateMgr.get();
|
||||
ctx.game = game.get();
|
||||
ctx.coopGame = coopGame.get();
|
||||
ctx.scores = nullptr;
|
||||
ctx.starfield = &starfield;
|
||||
ctx.starfield3D = &starfield3D;
|
||||
@ -761,7 +802,8 @@ void TetrisApp::Impl::runLoop()
|
||||
Settings::instance().setMusicEnabled(true);
|
||||
}
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_S)
|
||||
// K: Toggle sound effects (S is reserved for co-op movement)
|
||||
if (e.key.scancode == SDL_SCANCODE_K)
|
||||
{
|
||||
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
||||
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
||||
@ -837,27 +879,63 @@ void TetrisApp::Impl::runLoop()
|
||||
}
|
||||
|
||||
if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
|
||||
if (playerName.length() < 12) {
|
||||
playerName += e.text.text;
|
||||
// Support single-player and coop two-name entry
|
||||
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||
if (highScoreEntryIndex == 0) {
|
||||
if (playerName.length() < 12) playerName += e.text.text;
|
||||
} else {
|
||||
if (player2Name.length() < 12) player2Name += e.text.text;
|
||||
}
|
||||
} else {
|
||||
if (playerName.length() < 12) playerName += e.text.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!showHelpOverlay && 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";
|
||||
ensureScoresLoaded();
|
||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName);
|
||||
Settings::instance().setPlayerName(playerName);
|
||||
isNewHighScore = false;
|
||||
SDL_StopTextInput(window);
|
||||
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||
// Two-name entry flow
|
||||
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
|
||||
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
|
||||
else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back();
|
||||
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
||||
if (highScoreEntryIndex == 0) {
|
||||
if (playerName.empty()) playerName = "P1";
|
||||
highScoreEntryIndex = 1; // move to second name
|
||||
} else {
|
||||
if (player2Name.empty()) player2Name = "P2";
|
||||
// Submit combined name
|
||||
std::string combined = playerName + " & " + player2Name;
|
||||
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||
int combinedScore = leftScore + rightScore;
|
||||
ensureScoresLoaded();
|
||||
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
|
||||
Settings::instance().setPlayerName(playerName);
|
||||
isNewHighScore = false;
|
||||
SDL_StopTextInput(window);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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";
|
||||
ensureScoresLoaded();
|
||||
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);
|
||||
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) {
|
||||
if (game->getMode() == GameMode::Challenge) {
|
||||
game->startChallengeRun(1);
|
||||
} else if (game->getMode() == GameMode::Cooperate) {
|
||||
game->setMode(GameMode::Cooperate);
|
||||
game->reset(startLevelSelection);
|
||||
} else {
|
||||
game->setMode(GameMode::Endless);
|
||||
game->reset(startLevelSelection);
|
||||
@ -893,6 +971,13 @@ void TetrisApp::Impl::runLoop()
|
||||
if (game) game->setMode(GameMode::Endless);
|
||||
startMenuPlayTransition();
|
||||
break;
|
||||
case ui::BottomMenuItem::Cooperate:
|
||||
if (game) {
|
||||
game->setMode(GameMode::Cooperate);
|
||||
game->reset(startLevelSelection);
|
||||
}
|
||||
startMenuPlayTransition();
|
||||
break;
|
||||
case ui::BottomMenuItem::Challenge:
|
||||
if (game) {
|
||||
game->setMode(GameMode::Challenge);
|
||||
@ -1153,29 +1238,116 @@ void TetrisApp::Impl::runLoop()
|
||||
|
||||
if (state == AppState::Playing)
|
||||
{
|
||||
if (!game->isPaused()) {
|
||||
game->tickGravity(frameMs);
|
||||
game->updateElapsedTime();
|
||||
const bool coopActive = game && game->getMode() == GameMode::Cooperate && coopGame;
|
||||
|
||||
if (lineEffect.isActive()) {
|
||||
if (lineEffect.update(frameMs / 1000.0f)) {
|
||||
game->clearCompletedLines();
|
||||
if (coopActive) {
|
||||
// Coop DAS/ARR handling (per-side)
|
||||
const bool* ks = SDL_GetKeyboardState(nullptr);
|
||||
|
||||
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||
bool leftHeldPrev,
|
||||
bool rightHeldPrev,
|
||||
double& timer,
|
||||
SDL_Scancode leftKey,
|
||||
SDL_Scancode rightKey,
|
||||
SDL_Scancode downKey) {
|
||||
bool left = ks[leftKey];
|
||||
bool right = ks[rightKey];
|
||||
bool down = ks[downKey];
|
||||
|
||||
coopGame->setSoftDropping(side, down);
|
||||
|
||||
int moveDir = 0;
|
||||
if (left && !right) moveDir = -1;
|
||||
else if (right && !left) moveDir = +1;
|
||||
|
||||
if (moveDir != 0) {
|
||||
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
||||
coopGame->move(side, moveDir);
|
||||
timer = DAS;
|
||||
} else {
|
||||
timer -= frameMs;
|
||||
if (timer <= 0) {
|
||||
coopGame->move(side, moveDir);
|
||||
timer += ARR;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timer = 0.0;
|
||||
}
|
||||
};
|
||||
|
||||
if (game->isPaused()) {
|
||||
// While paused, suppress all continuous input changes so pieces don't drift.
|
||||
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||
p1MoveTimerMs = 0.0;
|
||||
p2MoveTimerMs = 0.0;
|
||||
p1LeftHeld = false;
|
||||
p1RightHeld = false;
|
||||
p2LeftHeld = false;
|
||||
p2RightHeld = false;
|
||||
} else {
|
||||
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
||||
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
||||
|
||||
p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||
p1RightHeld = ks[SDL_SCANCODE_D];
|
||||
p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||
p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||
|
||||
coopGame->tickGravity(frameMs);
|
||||
coopGame->updateVisualEffects(frameMs);
|
||||
}
|
||||
|
||||
if (coopGame->isGameOver()) {
|
||||
// Compute combined coop stats for Game Over
|
||||
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||
int combinedScore = leftScore + rightScore;
|
||||
if (combinedScore > 0) {
|
||||
isNewHighScore = true;
|
||||
playerName.clear();
|
||||
player2Name.clear();
|
||||
highScoreEntryIndex = 0;
|
||||
SDL_StartTextInput(window);
|
||||
} else {
|
||||
isNewHighScore = false;
|
||||
ensureScoresLoaded();
|
||||
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate");
|
||||
}
|
||||
state = AppState::GameOver;
|
||||
stateMgr->setState(state);
|
||||
}
|
||||
|
||||
} else {
|
||||
if (!game->isPaused()) {
|
||||
game->tickGravity(frameMs);
|
||||
game->updateElapsedTime();
|
||||
|
||||
if (lineEffect.isActive()) {
|
||||
if (lineEffect.update(frameMs / 1000.0f)) {
|
||||
game->clearCompletedLines();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (game->isGameOver())
|
||||
{
|
||||
if (game->score() > 0) {
|
||||
isNewHighScore = true;
|
||||
playerName.clear();
|
||||
SDL_StartTextInput(window);
|
||||
} else {
|
||||
isNewHighScore = false;
|
||||
ensureScoresLoaded();
|
||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed());
|
||||
if (game->isGameOver())
|
||||
{
|
||||
if (game->score() > 0) {
|
||||
isNewHighScore = true;
|
||||
playerName.clear();
|
||||
SDL_StartTextInput(window);
|
||||
} else {
|
||||
isNewHighScore = false;
|
||||
ensureScoresLoaded();
|
||||
{
|
||||
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt);
|
||||
}
|
||||
}
|
||||
state = AppState::GameOver;
|
||||
stateMgr->setState(state);
|
||||
}
|
||||
state = AppState::GameOver;
|
||||
stateMgr->setState(state);
|
||||
}
|
||||
}
|
||||
else if (state == AppState::Loading)
|
||||
@ -1866,32 +2038,44 @@ void TetrisApp::Impl::runLoop()
|
||||
SDL_RenderFillRect(renderer, &boxRect);
|
||||
|
||||
ensureScoresLoaded();
|
||||
bool realHighScore = scores.isHighScore(game->score());
|
||||
// Choose display values based on mode (single-player vs coop)
|
||||
int displayScore = 0;
|
||||
int displayLines = 0;
|
||||
int displayLevel = 0;
|
||||
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||
displayScore = leftScore + rightScore;
|
||||
displayLines = coopGame->lines();
|
||||
displayLevel = coopGame->level();
|
||||
} else if (game) {
|
||||
displayScore = game->score();
|
||||
displayLines = game->lines();
|
||||
displayLevel = game->level();
|
||||
}
|
||||
|
||||
bool realHighScore = scores.isHighScore(displayScore);
|
||||
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});
|
||||
|
||||
char scoreStr[64];
|
||||
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game->score());
|
||||
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", displayScore);
|
||||
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});
|
||||
|
||||
if (isNewHighScore) {
|
||||
const char* enterName = "ENTER NAME:";
|
||||
const bool isCoopEntry = (game && game->getMode() == GameMode::Cooperate && coopGame);
|
||||
const char* enterName = isCoopEntry ? "ENTER NAMES:" : "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});
|
||||
if (!isCoopEntry) {
|
||||
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
|
||||
}
|
||||
|
||||
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);
|
||||
const float inputW = isCoopEntry ? 260.0f : 300.0f;
|
||||
const float inputH = 40.0f;
|
||||
const float inputX = boxX + (boxW - inputW) * 0.5f;
|
||||
const float inputY = boxY + 200.0f;
|
||||
|
||||
const float nameScale = 1.2f;
|
||||
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
|
||||
@ -1900,34 +2084,67 @@ void TetrisApp::Impl::runLoop()
|
||||
pixelFont.measure("A", nameScale, metricsW, metricsH);
|
||||
if (metricsH == 0) metricsH = 24;
|
||||
|
||||
int nameW = 0, nameH = 0;
|
||||
if (!playerName.empty()) {
|
||||
pixelFont.measure(playerName, nameScale, nameW, nameH);
|
||||
// Single name entry (non-coop) --- keep original behavior
|
||||
if (!isCoopEntry) {
|
||||
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);
|
||||
|
||||
int nameW = 0, nameH = 0;
|
||||
if (!playerName.empty()) pixelFont.measure(playerName, nameScale, nameW, nameH);
|
||||
else nameH = metricsH;
|
||||
|
||||
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
||||
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
||||
|
||||
if (!playerName.empty()) pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255,255,255,255});
|
||||
|
||||
if (showCursor) {
|
||||
int cursorW = 0, cursorH = 0; pixelFont.measure("_", nameScale, cursorW, cursorH);
|
||||
float cursorX = playerName.empty() ? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX : textX + static_cast<float>(nameW);
|
||||
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
||||
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255});
|
||||
}
|
||||
|
||||
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 {
|
||||
nameH = metricsH;
|
||||
// Coop: prompt sequentially. First ask Player 1, then ask Player 2 after Enter.
|
||||
const bool askingP1 = (highScoreEntryIndex == 0);
|
||||
const char* label = askingP1 ? "PLAYER 1:" : "PLAYER 2:";
|
||||
int labW=0, labH=0; pixelFont.measure(label, 1.0f, labW, labH);
|
||||
pixelFont.draw(renderer, boxX + (boxW - labW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, label, 1.0f, {200,200,220,255});
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
||||
SDL_FRect rect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
|
||||
SDL_RenderFillRect(renderer, &rect);
|
||||
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
|
||||
SDL_RenderRect(renderer, &rect);
|
||||
|
||||
const std::string &activeName = askingP1 ? playerName : player2Name;
|
||||
int nameW = 0, nameH = 0;
|
||||
if (!activeName.empty()) pixelFont.measure(activeName, nameScale, nameW, nameH);
|
||||
else nameH = metricsH;
|
||||
|
||||
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
||||
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
||||
if (!activeName.empty()) pixelFont.draw(renderer, textX, textY, activeName, nameScale, {255,255,255,255});
|
||||
|
||||
if (showCursor) {
|
||||
int cursorW=0, cursorH=0; pixelFont.measure("_", nameScale, cursorW, cursorH);
|
||||
float cursorX = activeName.empty() ? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX : textX + static_cast<float>(nameW);
|
||||
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
||||
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255});
|
||||
}
|
||||
|
||||
const char* hint = askingP1 ? "PRESS ENTER FOR NEXT NAME" : "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 + 300 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
|
||||
}
|
||||
|
||||
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
||||
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
||||
|
||||
if (!playerName.empty()) {
|
||||
pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255});
|
||||
}
|
||||
|
||||
if (showCursor) {
|
||||
int cursorW = 0, cursorH = 0;
|
||||
pixelFont.measure("_", nameScale, cursorW, cursorH);
|
||||
float cursorX = playerName.empty()
|
||||
? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX
|
||||
: textX + static_cast<float>(nameW);
|
||||
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
||||
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255});
|
||||
}
|
||||
|
||||
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 {
|
||||
char linesStr[64];
|
||||
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines());
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
#include "../../graphics/effects/Starfield.h"
|
||||
#include "../../graphics/renderers/GameRenderer.h"
|
||||
#include "../../gameplay/core/Game.h"
|
||||
#include "../../gameplay/coop/CoopGame.h"
|
||||
#include "../../gameplay/effects/LineEffect.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
@ -561,6 +562,7 @@ bool ApplicationManager::initializeGame() {
|
||||
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
||||
}
|
||||
m_game = std::make_unique<Game>(m_startLevelSelection);
|
||||
m_coopGame = std::make_unique<CoopGame>(m_startLevelSelection);
|
||||
// Wire up sound callbacks as main.cpp did
|
||||
if (m_game) {
|
||||
// Apply global gravity speed multiplier from config
|
||||
@ -580,6 +582,18 @@ bool ApplicationManager::initializeGame() {
|
||||
});
|
||||
}
|
||||
|
||||
if (m_coopGame) {
|
||||
// TODO: tune gravity with Config and shared level scaling once coop rules are finalized
|
||||
m_coopGame->reset(m_startLevelSelection);
|
||||
// Wire coop sound callback to reuse same clear-line VO/SFX behavior
|
||||
m_coopGame->setSoundCallback([&](int linesCleared){
|
||||
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||
if (linesCleared == 2) SoundEffectManager::instance().playRandomSound({"nice_combo"}, 1.0f);
|
||||
else if (linesCleared == 3) SoundEffectManager::instance().playRandomSound({"great_move"}, 1.0f);
|
||||
else if (linesCleared == 4) SoundEffectManager::instance().playRandomSound({"amazing"}, 1.0f);
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare a StateContext-like struct by setting up handlers that capture
|
||||
// pointers and flags. State objects in this refactor expect these to be
|
||||
// available via StateManager event/update/render hooks, so we'll store them
|
||||
@ -621,6 +635,7 @@ bool ApplicationManager::initializeGame() {
|
||||
{
|
||||
m_stateContext.stateManager = m_stateManager.get();
|
||||
m_stateContext.game = m_game.get();
|
||||
m_stateContext.coopGame = m_coopGame.get();
|
||||
m_stateContext.scores = m_scoreManager.get();
|
||||
m_stateContext.starfield = m_starfield.get();
|
||||
m_stateContext.starfield3D = m_starfield3D.get();
|
||||
@ -917,8 +932,8 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_showExitConfirmPopup = true;
|
||||
return;
|
||||
}
|
||||
// S: toggle SFX enable state (music handled globally)
|
||||
if (event.key.scancode == SDL_SCANCODE_S) {
|
||||
// K: toggle SFX enable state (music handled globally)
|
||||
if (event.key.scancode == SDL_SCANCODE_K) {
|
||||
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
||||
}
|
||||
}
|
||||
@ -1217,13 +1232,25 @@ void ApplicationManager::setupStateHandlers() {
|
||||
// "GAME OVER" title
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, {255, 80, 60, 255});
|
||||
|
||||
// Game stats
|
||||
// Game stats (single-player or coop combined)
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level());
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 180, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||
if (m_stateContext.game && m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame) {
|
||||
int leftScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Left);
|
||||
int rightScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Right);
|
||||
int total = leftScore + rightScore;
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d + %d = %d LINES %d LEVEL %d",
|
||||
leftScore,
|
||||
rightScore,
|
||||
total,
|
||||
m_stateContext.coopGame->lines(),
|
||||
m_stateContext.coopGame->level());
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
||||
m_stateContext.game ? m_stateContext.game->score() : 0,
|
||||
m_stateContext.game ? m_stateContext.game->lines() : 0,
|
||||
m_stateContext.game ? m_stateContext.game->level() : 0);
|
||||
}
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 220, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||
|
||||
// Instructions
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255});
|
||||
@ -1238,73 +1265,159 @@ void ApplicationManager::setupStateHandlers() {
|
||||
[this](double frameMs) {
|
||||
if (!m_stateContext.game) return;
|
||||
|
||||
const bool coopActive = m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame;
|
||||
|
||||
// Get current keyboard state
|
||||
const bool *ks = SDL_GetKeyboardState(nullptr);
|
||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||
|
||||
// Handle soft drop
|
||||
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
||||
if (coopActive) {
|
||||
// Paused: suppress all continuous input so pieces don't drift while paused.
|
||||
if (m_stateContext.game->isPaused()) {
|
||||
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||
m_p1MoveTimerMs = 0.0;
|
||||
m_p2MoveTimerMs = 0.0;
|
||||
m_p1LeftHeld = false;
|
||||
m_p1RightHeld = false;
|
||||
m_p2LeftHeld = false;
|
||||
m_p2RightHeld = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle DAS/ARR movement timing (from original main.cpp)
|
||||
int moveDir = 0;
|
||||
if (left && !right)
|
||||
moveDir = -1;
|
||||
else if (right && !left)
|
||||
moveDir = +1;
|
||||
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||
bool leftHeld,
|
||||
bool rightHeld,
|
||||
double& timer,
|
||||
SDL_Scancode leftKey,
|
||||
SDL_Scancode rightKey,
|
||||
SDL_Scancode downKey) {
|
||||
bool left = ks[leftKey];
|
||||
bool right = ks[rightKey];
|
||||
bool down = ks[downKey];
|
||||
|
||||
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
||||
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs = DAS; // Set initial delay
|
||||
} else {
|
||||
// Key held - handle repeat timing
|
||||
m_moveTimerMs -= frameMs;
|
||||
if (m_moveTimerMs <= 0) {
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs += ARR; // Set repeat rate
|
||||
// Soft drop flag
|
||||
m_stateContext.coopGame->setSoftDropping(side, down);
|
||||
|
||||
int moveDir = 0;
|
||||
if (left && !right) moveDir = -1;
|
||||
else if (right && !left) moveDir = +1;
|
||||
|
||||
if (moveDir != 0) {
|
||||
if ((moveDir == -1 && !leftHeld) || (moveDir == +1 && !rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.coopGame->move(side, moveDir);
|
||||
timer = DAS;
|
||||
} else {
|
||||
timer -= frameMs;
|
||||
if (timer <= 0) {
|
||||
m_stateContext.coopGame->move(side, moveDir);
|
||||
timer += ARR;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timer = 0.0;
|
||||
}
|
||||
|
||||
// Soft drop boost: coop uses same gravity path; fall acceleration handled inside tickGravity
|
||||
};
|
||||
|
||||
// Left player (WASD): A/D horizontal, S soft drop
|
||||
handleSide(CoopGame::PlayerSide::Left, m_p1LeftHeld, m_p1RightHeld, m_p1MoveTimerMs,
|
||||
SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
||||
// Right player (arrows): Left/Right horizontal, Down soft drop
|
||||
handleSide(CoopGame::PlayerSide::Right, m_p2LeftHeld, m_p2RightHeld, m_p2MoveTimerMs,
|
||||
SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
||||
|
||||
// Update held flags for next frame
|
||||
m_p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||
m_p1RightHeld = ks[SDL_SCANCODE_D];
|
||||
m_p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||
m_p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||
|
||||
// Gravity / effects
|
||||
m_stateContext.coopGame->tickGravity(frameMs);
|
||||
m_stateContext.coopGame->updateVisualEffects(frameMs);
|
||||
|
||||
// Delegate to PlayingState for any ancillary updates (renderer transport bookkeeping)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Game over transition for coop
|
||||
if (m_stateContext.coopGame->isGameOver()) {
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
|
||||
} else {
|
||||
m_moveTimerMs = 0; // Reset timer when no movement
|
||||
}
|
||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||
|
||||
// Update held state for next frame
|
||||
m_leftHeld = left;
|
||||
m_rightHeld = right;
|
||||
// Handle soft drop
|
||||
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
||||
|
||||
// Handle soft drop boost
|
||||
if (down && !m_stateContext.game->isPaused()) {
|
||||
m_stateContext.game->softDropBoost(frameMs);
|
||||
}
|
||||
// Handle DAS/ARR movement timing (from original main.cpp)
|
||||
int moveDir = 0;
|
||||
if (left && !right)
|
||||
moveDir = -1;
|
||||
else if (right && !left)
|
||||
moveDir = +1;
|
||||
|
||||
// Delegate to PlayingState for other updates (gravity, line effects)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Update background fade progression (match main.cpp semantics approx)
|
||||
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
||||
const float LEVEL_FADE_DURATION = 1200.0f;
|
||||
if (m_nextLevelBackgroundTex) {
|
||||
m_levelFadeElapsed += (float)frameMs;
|
||||
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
||||
}
|
||||
|
||||
// Check for game over and transition to GameOver state
|
||||
if (m_stateContext.game->isGameOver()) {
|
||||
// Submit score before transitioning
|
||||
if (m_stateContext.scores) {
|
||||
m_stateContext.scores->submit(
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level(),
|
||||
m_stateContext.game->elapsed()
|
||||
);
|
||||
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
||||
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs = DAS; // Set initial delay
|
||||
} else {
|
||||
// Key held - handle repeat timing
|
||||
m_moveTimerMs -= frameMs;
|
||||
if (m_moveTimerMs <= 0) {
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs += ARR; // Set repeat rate
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_moveTimerMs = 0; // Reset timer when no movement
|
||||
}
|
||||
|
||||
// Update held state for next frame
|
||||
m_leftHeld = left;
|
||||
m_rightHeld = right;
|
||||
|
||||
// Handle soft drop boost
|
||||
if (down && !m_stateContext.game->isPaused()) {
|
||||
m_stateContext.game->softDropBoost(frameMs);
|
||||
}
|
||||
|
||||
// Delegate to PlayingState for other updates (gravity, line effects)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Update background fade progression (match main.cpp semantics approx)
|
||||
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
||||
const float LEVEL_FADE_DURATION = 1200.0f;
|
||||
if (m_nextLevelBackgroundTex) {
|
||||
m_levelFadeElapsed += (float)frameMs;
|
||||
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
||||
}
|
||||
|
||||
// Check for game over and transition to GameOver state
|
||||
if (m_stateContext.game->isGameOver()) {
|
||||
// Submit score before transitioning
|
||||
if (m_stateContext.scores) {
|
||||
std::string gt = (m_stateContext.game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||
m_stateContext.scores->submit(
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level(),
|
||||
m_stateContext.game->elapsed(),
|
||||
std::string("PLAYER"),
|
||||
gt
|
||||
);
|
||||
}
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
});
|
||||
// Debug overlay: show current window and logical sizes on the right side of the screen
|
||||
|
||||
@ -17,6 +17,7 @@ class Starfield;
|
||||
class Starfield3D;
|
||||
class FontAtlas;
|
||||
class LineEffect;
|
||||
class CoopGame;
|
||||
|
||||
// Forward declare state classes (top-level, defined under src/states)
|
||||
class LoadingState;
|
||||
@ -109,6 +110,7 @@ private:
|
||||
std::unique_ptr<ScoreManager> m_scoreManager;
|
||||
// Gameplay pieces
|
||||
std::unique_ptr<Game> m_game;
|
||||
std::unique_ptr<CoopGame> m_coopGame;
|
||||
std::unique_ptr<LineEffect> m_lineEffect;
|
||||
|
||||
// DAS/ARR movement timing (from original main.cpp)
|
||||
@ -118,6 +120,14 @@ private:
|
||||
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
||||
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
||||
|
||||
// Coop DAS/ARR per player
|
||||
bool m_p1LeftHeld = false;
|
||||
bool m_p1RightHeld = false;
|
||||
bool m_p2LeftHeld = false;
|
||||
bool m_p2RightHeld = false;
|
||||
double m_p1MoveTimerMs = 0.0;
|
||||
double m_p2MoveTimerMs = 0.0;
|
||||
|
||||
// State context (must be a member to ensure lifetime)
|
||||
StateContext m_stateContext;
|
||||
|
||||
|
||||
498
src/gameplay/coop/CoopGame.cpp
Normal file
498
src/gameplay/coop/CoopGame.cpp
Normal file
@ -0,0 +1,498 @@
|
||||
#include "CoopGame.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
||||
constexpr double NES_FPS = 60.0988;
|
||||
constexpr double FRAME_MS = 1000.0 / NES_FPS;
|
||||
|
||||
struct LevelGravity { int framesPerCell; double levelMultiplier; };
|
||||
|
||||
LevelGravity LEVEL_TABLE[30] = {
|
||||
{48,1.0}, {43,1.0}, {38,1.0}, {33,1.0}, {28,1.0}, {23,1.0}, {18,1.0}, {13,1.0}, {8,1.0}, {6,1.0},
|
||||
{5,1.0}, {5,1.0}, {5,1.0}, {4,1.0}, {4,1.0}, {4,1.0}, {3,1.0}, {3,1.0}, {3,1.0}, {2,1.0},
|
||||
{2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {1,1.0}
|
||||
};
|
||||
|
||||
inline double gravityMsForLevelInternal(int level, double globalMultiplier) {
|
||||
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
|
||||
const LevelGravity& lg = LEVEL_TABLE[idx];
|
||||
double frames = lg.framesPerCell * lg.levelMultiplier;
|
||||
return frames * FRAME_MS * globalMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0).
|
||||
static const std::array<Shape, PIECE_COUNT> SHAPES = {{
|
||||
Shape{ 0x0F00, 0x2222, 0x00F0, 0x4444 }, // I
|
||||
Shape{ 0x0660, 0x0660, 0x0660, 0x0660 }, // O
|
||||
Shape{ 0x0E40, 0x4C40, 0x4E00, 0x4640 }, // T
|
||||
Shape{ 0x06C0, 0x4620, 0x06C0, 0x4620 }, // S
|
||||
Shape{ 0x0C60, 0x2640, 0x0C60, 0x2640 }, // Z
|
||||
Shape{ 0x08E0, 0x6440, 0x0E20, 0x44C0 }, // J
|
||||
Shape{ 0x02E0, 0x4460, 0x0E80, 0xC440 }, // L
|
||||
}};
|
||||
}
|
||||
|
||||
CoopGame::CoopGame(int startLevel_) {
|
||||
reset(startLevel_);
|
||||
}
|
||||
|
||||
void CoopGame::reset(int startLevel_) {
|
||||
std::fill(board.begin(), board.end(), Cell{});
|
||||
rowStates.fill(RowHalfState{});
|
||||
completedLines.clear();
|
||||
hardDropCells.clear();
|
||||
hardDropFxId = 0;
|
||||
hardDropShakeTimerMs = 0.0;
|
||||
_score = 0;
|
||||
_lines = 0;
|
||||
_level = startLevel_;
|
||||
startLevel = startLevel_;
|
||||
gravityMs = gravityMsForLevel(_level);
|
||||
gameOver = false;
|
||||
pieceSequence = 0;
|
||||
elapsedMs = 0.0;
|
||||
|
||||
left = PlayerState{};
|
||||
right = PlayerState{ PlayerSide::Right };
|
||||
|
||||
auto initPlayer = [&](PlayerState& ps) {
|
||||
ps.canHold = true;
|
||||
ps.hold.type = PIECE_COUNT;
|
||||
ps.softDropping = false;
|
||||
ps.toppedOut = false;
|
||||
ps.fallAcc = 0.0;
|
||||
ps.lockAcc = 0.0;
|
||||
ps.pieceSeq = 0;
|
||||
ps.score = 0;
|
||||
ps.lines = 0;
|
||||
ps.level = startLevel_;
|
||||
ps.tetrisesMade = 0;
|
||||
ps.currentCombo = 0;
|
||||
ps.maxCombo = 0;
|
||||
ps.comboCount = 0;
|
||||
ps.bag.clear();
|
||||
ps.next.type = PIECE_COUNT;
|
||||
refillBag(ps);
|
||||
};
|
||||
initPlayer(left);
|
||||
initPlayer(right);
|
||||
|
||||
spawn(left);
|
||||
spawn(right);
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
||||
PlayerState& ps = player(side);
|
||||
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
||||
double oldStep = stepFor(ps.softDropping);
|
||||
double newStep = stepFor(on);
|
||||
if (oldStep <= 0.0 || newStep <= 0.0) {
|
||||
ps.softDropping = on;
|
||||
return;
|
||||
}
|
||||
|
||||
double progress = ps.fallAcc / oldStep;
|
||||
progress = std::clamp(progress, 0.0, 1.0);
|
||||
ps.fallAcc = progress * newStep;
|
||||
ps.softDropping = on;
|
||||
}
|
||||
|
||||
void CoopGame::move(PlayerSide side, int dx) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
tryMove(ps, dx, 0);
|
||||
}
|
||||
|
||||
void CoopGame::rotate(PlayerSide side, int dir) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
|
||||
auto minOccupiedY = [&](const Piece& p) -> int {
|
||||
int minY = 999;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(p, cx, cy)) continue;
|
||||
minY = std::min(minY, p.y + cy);
|
||||
}
|
||||
}
|
||||
return (minY == 999) ? p.y : minY;
|
||||
};
|
||||
|
||||
auto tryApplyWithTopKick = [&](const Piece& candidate) -> bool {
|
||||
// If rotation would place any occupied cell above the visible grid,
|
||||
// kick it down just enough to keep all blocks visible.
|
||||
int minY = minOccupiedY(candidate);
|
||||
int baseDy = (minY < 0) ? -minY : 0;
|
||||
|
||||
// Try minimal adjustment first; allow a couple extra pixels/rows for safety.
|
||||
for (int dy = baseDy; dy <= baseDy + 2; ++dy) {
|
||||
Piece test = candidate;
|
||||
test.y += dy;
|
||||
if (!collides(ps, test)) {
|
||||
ps.cur = test;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Piece rotated = ps.cur;
|
||||
rotated.rot = (rotated.rot + dir + 4) % 4;
|
||||
|
||||
// Simple wall kick: try in place, then left, then right.
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
rotated.x -= 1;
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
rotated.x += 2;
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
}
|
||||
|
||||
void CoopGame::hardDrop(PlayerSide side) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
|
||||
hardDropCells.clear();
|
||||
bool moved = false;
|
||||
int dropped = 0;
|
||||
while (tryMove(ps, 0, 1)) {
|
||||
moved = true;
|
||||
dropped++;
|
||||
// Record path for potential effects
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||
int px = ps.cur.x + cx;
|
||||
int py = ps.cur.y + cy;
|
||||
if (py >= 0) {
|
||||
hardDropCells.push_back(SDL_Point{ px, py });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (moved) {
|
||||
_score += dropped; // 1 point per cell, matches single-player hard drop
|
||||
ps.score += dropped;
|
||||
hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS;
|
||||
hardDropFxId++;
|
||||
}
|
||||
lock(ps);
|
||||
}
|
||||
|
||||
void CoopGame::holdCurrent(PlayerSide side) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
if (!ps.canHold) return;
|
||||
if (ps.hold.type == PIECE_COUNT) {
|
||||
ps.hold = ps.cur;
|
||||
spawn(ps);
|
||||
} else {
|
||||
std::swap(ps.cur, ps.hold);
|
||||
ps.cur.rot = 0;
|
||||
ps.cur.x = columnMin(ps.side) + 3;
|
||||
// Match single-player spawn height (I starts higher)
|
||||
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||
ps.pieceSeq++;
|
||||
pieceSequence++;
|
||||
}
|
||||
ps.canHold = false;
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
|
||||
void CoopGame::tickGravity(double frameMs) {
|
||||
if (gameOver) return;
|
||||
|
||||
elapsedMs += frameMs;
|
||||
|
||||
auto stepPlayer = [&](PlayerState& ps) {
|
||||
if (ps.toppedOut) return;
|
||||
double step = ps.softDropping ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||
ps.fallAcc += frameMs;
|
||||
while (ps.fallAcc >= step) {
|
||||
ps.fallAcc -= step;
|
||||
if (!tryMove(ps, 0, 1)) {
|
||||
ps.lockAcc += step;
|
||||
if (ps.lockAcc >= LOCK_DELAY_MS) {
|
||||
lock(ps);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Award soft drop points when actively holding down
|
||||
if (ps.softDropping) {
|
||||
_score += 1;
|
||||
ps.score += 1;
|
||||
}
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stepPlayer(left);
|
||||
stepPlayer(right);
|
||||
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::updateVisualEffects(double frameMs) {
|
||||
if (hardDropShakeTimerMs > 0.0) {
|
||||
hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs);
|
||||
}
|
||||
}
|
||||
|
||||
double CoopGame::hardDropShakeStrength() const {
|
||||
if (hardDropShakeTimerMs <= 0.0) return 0.0;
|
||||
return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0);
|
||||
}
|
||||
|
||||
double CoopGame::gravityMsForLevel(int level) const {
|
||||
return gravityMsForLevelInternal(level, gravityGlobalMultiplier);
|
||||
}
|
||||
|
||||
bool CoopGame::cellFilled(const Piece& p, int cx, int cy) {
|
||||
if (p.type >= PIECE_COUNT) return false;
|
||||
const Shape& shape = SHAPES[p.type];
|
||||
uint16_t mask = shape[p.rot % 4];
|
||||
int bitIndex = cy * 4 + cx;
|
||||
// Masks are defined row-major 4x4 with bit 0 = (0,0) (same convention as classic).
|
||||
return (mask >> bitIndex) & 1;
|
||||
}
|
||||
|
||||
void CoopGame::clearCompletedLines() {
|
||||
if (completedLines.empty()) return;
|
||||
clearLinesInternal();
|
||||
completedLines.clear();
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::refillBag(PlayerState& ps) {
|
||||
ps.bag.clear();
|
||||
ps.bag.reserve(PIECE_COUNT);
|
||||
for (int i = 0; i < PIECE_COUNT; ++i) {
|
||||
ps.bag.push_back(static_cast<PieceType>(i));
|
||||
}
|
||||
std::shuffle(ps.bag.begin(), ps.bag.end(), ps.rng);
|
||||
}
|
||||
|
||||
CoopGame::Piece CoopGame::drawFromBag(PlayerState& ps) {
|
||||
if (ps.bag.empty()) {
|
||||
refillBag(ps);
|
||||
}
|
||||
PieceType t = ps.bag.back();
|
||||
ps.bag.pop_back();
|
||||
Piece p{};
|
||||
p.type = t;
|
||||
return p;
|
||||
}
|
||||
|
||||
void CoopGame::spawn(PlayerState& ps) {
|
||||
if (ps.next.type == PIECE_COUNT) {
|
||||
ps.next = drawFromBag(ps);
|
||||
}
|
||||
ps.cur = ps.next;
|
||||
ps.cur.rot = 0;
|
||||
ps.cur.x = columnMin(ps.side) + 3; // center within side
|
||||
// Match single-player spawn height (I starts higher)
|
||||
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||
ps.next = drawFromBag(ps);
|
||||
ps.canHold = true;
|
||||
ps.softDropping = false;
|
||||
ps.lockAcc = 0.0;
|
||||
ps.fallAcc = 0.0;
|
||||
ps.pieceSeq++;
|
||||
pieceSequence++;
|
||||
if (collides(ps, ps.cur)) {
|
||||
ps.toppedOut = true;
|
||||
if (left.toppedOut && right.toppedOut) {
|
||||
gameOver = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
||||
int minX = columnMin(ps.side);
|
||||
int maxX = columnMax(ps.side);
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(p, cx, cy)) continue;
|
||||
int px = p.x + cx;
|
||||
int py = p.y + cy;
|
||||
if (px < minX || px > maxX) return true;
|
||||
if (py >= ROWS) return true;
|
||||
if (py < 0) continue; // allow spawn above board
|
||||
int idx = py * COLS + px;
|
||||
if (board[idx].occupied) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CoopGame::tryMove(PlayerState& ps, int dx, int dy) {
|
||||
Piece test = ps.cur;
|
||||
test.x += dx;
|
||||
test.y += dy;
|
||||
if (collides(ps, test)) return false;
|
||||
ps.cur = test;
|
||||
if (dy > 0) {
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CoopGame::lock(PlayerState& ps) {
|
||||
// Write piece into the board
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||
int px = ps.cur.x + cx;
|
||||
int py = ps.cur.y + cy;
|
||||
if (py < 0 || py >= ROWS) continue;
|
||||
int idx = py * COLS + px;
|
||||
board[idx].occupied = true;
|
||||
board[idx].owner = ps.side;
|
||||
board[idx].value = static_cast<int>(ps.cur.type) + 1;
|
||||
}
|
||||
}
|
||||
// Detect completed lines and apply rewards but DO NOT clear them here.
|
||||
// Clearing is deferred to the visual `LineEffect` system (as in single-player)
|
||||
findCompletedLines();
|
||||
if (!completedLines.empty()) {
|
||||
int cleared = static_cast<int>(completedLines.size());
|
||||
applyLineClearRewards(ps, cleared);
|
||||
// Notify audio layer if present (matches single-player behavior)
|
||||
if (soundCallback) soundCallback(cleared);
|
||||
// Leave `completedLines` populated; `clearCompletedLines()` will be
|
||||
// invoked by the state when the LineEffect finishes.
|
||||
} else {
|
||||
_currentCombo = 0;
|
||||
ps.currentCombo = 0;
|
||||
}
|
||||
spawn(ps);
|
||||
}
|
||||
|
||||
void CoopGame::findCompletedLines() {
|
||||
completedLines.clear();
|
||||
for (int r = 0; r < ROWS; ++r) {
|
||||
bool leftFull = true;
|
||||
bool rightFull = true;
|
||||
for (int c = 0; c < COLS; ++c) {
|
||||
const Cell& cell = board[r * COLS + c];
|
||||
if (!cell.occupied) {
|
||||
if (c < 10) leftFull = false; else rightFull = false;
|
||||
}
|
||||
}
|
||||
rowStates[r].leftFull = leftFull;
|
||||
rowStates[r].rightFull = rightFull;
|
||||
if (leftFull && rightFull) {
|
||||
completedLines.push_back(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) {
|
||||
if (cleared <= 0) return;
|
||||
|
||||
// Base NES scoring scaled by shared level (level 0 => 1x multiplier)
|
||||
int base = 0;
|
||||
switch (cleared) {
|
||||
case 1: base = 40; break;
|
||||
case 2: base = 100; break;
|
||||
case 3: base = 300; break;
|
||||
case 4: base = 1200; break;
|
||||
default: base = 0; break;
|
||||
}
|
||||
_score += base * (_level + 1);
|
||||
creditPlayer.score += base * (creditPlayer.level + 1);
|
||||
|
||||
// Also award a trivial per-line bonus to both players so clears benefit
|
||||
// both participants equally (as requested).
|
||||
if (cleared > 0) {
|
||||
left.score += cleared;
|
||||
right.score += cleared;
|
||||
}
|
||||
|
||||
_lines += cleared;
|
||||
// Credit both players with the cleared lines so cooperative play counts for both
|
||||
left.lines += cleared;
|
||||
right.lines += cleared;
|
||||
|
||||
_currentCombo += 1;
|
||||
if (_currentCombo > _maxCombo) _maxCombo = _currentCombo;
|
||||
if (cleared > 1) {
|
||||
_comboCount += 1;
|
||||
}
|
||||
if (cleared == 4) {
|
||||
_tetrisesMade += 1;
|
||||
}
|
||||
|
||||
creditPlayer.currentCombo += 1;
|
||||
if (creditPlayer.currentCombo > creditPlayer.maxCombo) creditPlayer.maxCombo = creditPlayer.currentCombo;
|
||||
if (cleared > 1) {
|
||||
creditPlayer.comboCount += 1;
|
||||
}
|
||||
if (cleared == 4) {
|
||||
creditPlayer.tetrisesMade += 1;
|
||||
}
|
||||
|
||||
// Level progression mirrors single-player: threshold after (startLevel+1)*10 then every 10 lines
|
||||
int targetLevel = startLevel;
|
||||
int firstThreshold = (startLevel + 1) * 10;
|
||||
if (_lines >= firstThreshold) {
|
||||
targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10;
|
||||
}
|
||||
if (targetLevel > _level) {
|
||||
_level = targetLevel;
|
||||
gravityMs = gravityMsForLevel(_level);
|
||||
if (levelUpCallback) levelUpCallback(_level);
|
||||
}
|
||||
|
||||
// Per-player level progression mirrors the shared rules but is driven by
|
||||
// that player's credited line clears.
|
||||
{
|
||||
int pTargetLevel = startLevel;
|
||||
int pFirstThreshold = (startLevel + 1) * 10;
|
||||
if (creditPlayer.lines >= pFirstThreshold) {
|
||||
pTargetLevel = startLevel + 1 + (creditPlayer.lines - pFirstThreshold) / 10;
|
||||
}
|
||||
creditPlayer.level = std::max(creditPlayer.level, pTargetLevel);
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::clearLinesInternal() {
|
||||
if (completedLines.empty()) return;
|
||||
std::sort(completedLines.begin(), completedLines.end());
|
||||
for (int idx = static_cast<int>(completedLines.size()) - 1; idx >= 0; --idx) {
|
||||
int row = completedLines[idx];
|
||||
for (int y = row; y > 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[y * COLS + x] = board[(y - 1) * COLS + x];
|
||||
}
|
||||
}
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[x] = Cell{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sound callback (optional) - invoked when lines are detected so audio can play
|
||||
// (set via setSoundCallback)
|
||||
// NOTE: defined inline in header as a std::function member; forward usage above
|
||||
|
||||
void CoopGame::updateRowStates() {
|
||||
for (int r = 0; r < ROWS; ++r) {
|
||||
bool leftFull = true;
|
||||
bool rightFull = true;
|
||||
for (int c = 0; c < COLS; ++c) {
|
||||
const Cell& cell = board[r * COLS + c];
|
||||
if (!cell.occupied) {
|
||||
if (c < 10) leftFull = false; else rightFull = false;
|
||||
}
|
||||
}
|
||||
rowStates[r].leftFull = leftFull;
|
||||
rowStates[r].rightFull = rightFull;
|
||||
}
|
||||
}
|
||||
161
src/gameplay/coop/CoopGame.h
Normal file
161
src/gameplay/coop/CoopGame.h
Normal file
@ -0,0 +1,161 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include "../core/Game.h" // For PieceType enums and gravity table helpers
|
||||
|
||||
// Cooperative two-player session with a shared 20-column board split into halves.
|
||||
// This is an early scaffold: rules and rendering hooks will be iterated in follow-up passes.
|
||||
class CoopGame {
|
||||
public:
|
||||
enum class PlayerSide { Left, Right };
|
||||
|
||||
static constexpr int COLS = 20;
|
||||
static constexpr int ROWS = Game::ROWS;
|
||||
static constexpr int TILE = Game::TILE;
|
||||
|
||||
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{0}; int y{-2}; };
|
||||
|
||||
struct Cell {
|
||||
int value{0}; // 0 empty else color index (1..7)
|
||||
PlayerSide owner{PlayerSide::Left};
|
||||
bool occupied{false};
|
||||
};
|
||||
|
||||
struct RowHalfState {
|
||||
bool leftFull{false};
|
||||
bool rightFull{false};
|
||||
};
|
||||
|
||||
struct PlayerState {
|
||||
PlayerSide side{PlayerSide::Left};
|
||||
Piece cur{};
|
||||
Piece hold{};
|
||||
Piece next{};
|
||||
uint64_t pieceSeq{0};
|
||||
bool canHold{true};
|
||||
bool softDropping{false};
|
||||
bool toppedOut{false};
|
||||
double fallAcc{0.0};
|
||||
double lockAcc{0.0};
|
||||
int score{0};
|
||||
int lines{0};
|
||||
int level{0};
|
||||
int tetrisesMade{0};
|
||||
int currentCombo{0};
|
||||
int maxCombo{0};
|
||||
int comboCount{0};
|
||||
std::vector<PieceType> bag{}; // 7-bag queue
|
||||
std::mt19937 rng{ std::random_device{}() };
|
||||
};
|
||||
|
||||
explicit CoopGame(int startLevel = 0);
|
||||
using SoundCallback = std::function<void(int)>;
|
||||
using LevelUpCallback = std::function<void(int)>;
|
||||
void setSoundCallback(SoundCallback cb) { soundCallback = cb; }
|
||||
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
|
||||
|
||||
void reset(int startLevel = 0);
|
||||
void tickGravity(double frameMs);
|
||||
void updateVisualEffects(double frameMs);
|
||||
|
||||
// Per-player inputs -----------------------------------------------------
|
||||
void setSoftDropping(PlayerSide side, bool on);
|
||||
void move(PlayerSide side, int dx);
|
||||
void rotate(PlayerSide side, int dir); // +1 cw, -1 ccw
|
||||
void hardDrop(PlayerSide side);
|
||||
void holdCurrent(PlayerSide side);
|
||||
|
||||
// Accessors -------------------------------------------------------------
|
||||
const std::array<Cell, COLS * ROWS>& boardRef() const { return board; }
|
||||
const Piece& current(PlayerSide s) const { return player(s).cur; }
|
||||
const Piece& next(PlayerSide s) const { return player(s).next; }
|
||||
const Piece& held(PlayerSide s) const { return player(s).hold; }
|
||||
bool canHold(PlayerSide s) const { return player(s).canHold; }
|
||||
bool isGameOver() const { return gameOver; }
|
||||
int score() const { return _score; }
|
||||
int score(PlayerSide s) const { return player(s).score; }
|
||||
int lines() const { return _lines; }
|
||||
int lines(PlayerSide s) const { return player(s).lines; }
|
||||
int level() const { return _level; }
|
||||
int level(PlayerSide s) const { return player(s).level; }
|
||||
int comboCount() const { return _comboCount; }
|
||||
int maxCombo() const { return _maxCombo; }
|
||||
int tetrisesMade() const { return _tetrisesMade; }
|
||||
int elapsed() const { return static_cast<int>(elapsedMs / 1000.0); }
|
||||
int elapsed(PlayerSide) const { return elapsed(); }
|
||||
int startLevelBase() const { return startLevel; }
|
||||
double getGravityMs() const { return gravityMs; }
|
||||
double getFallAccumulator(PlayerSide s) const { return player(s).fallAcc; }
|
||||
bool isSoftDropping(PlayerSide s) const { return player(s).softDropping; }
|
||||
uint64_t currentPieceSequence(PlayerSide s) const { return player(s).pieceSeq; }
|
||||
const std::vector<int>& getCompletedLines() const { return completedLines; }
|
||||
bool hasCompletedLines() const { return !completedLines.empty(); }
|
||||
void clearCompletedLines();
|
||||
const std::array<RowHalfState, ROWS>& rowHalfStates() const { return rowStates; }
|
||||
|
||||
// Simple visual-effect compatibility (stubbed for now)
|
||||
bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; }
|
||||
double hardDropShakeStrength() const;
|
||||
const std::vector<SDL_Point>& getHardDropCells() const { return hardDropCells; }
|
||||
uint32_t getHardDropFxId() const { return hardDropFxId; }
|
||||
|
||||
static bool cellFilled(const Piece& p, int cx, int cy);
|
||||
|
||||
private:
|
||||
static constexpr double LOCK_DELAY_MS = 500.0;
|
||||
|
||||
std::array<Cell, COLS * ROWS> board{};
|
||||
std::array<RowHalfState, ROWS> rowStates{};
|
||||
PlayerState left{};
|
||||
PlayerState right{ PlayerSide::Right };
|
||||
|
||||
int _score{0};
|
||||
int _lines{0};
|
||||
int _level{1};
|
||||
int _tetrisesMade{0};
|
||||
int _currentCombo{0};
|
||||
int _maxCombo{0};
|
||||
int _comboCount{0};
|
||||
int startLevel{0};
|
||||
double gravityMs{800.0};
|
||||
double gravityGlobalMultiplier{1.0};
|
||||
bool gameOver{false};
|
||||
|
||||
double elapsedMs{0.0};
|
||||
|
||||
std::vector<int> completedLines;
|
||||
|
||||
// Impact FX
|
||||
double hardDropShakeTimerMs{0.0};
|
||||
static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0;
|
||||
std::vector<SDL_Point> hardDropCells;
|
||||
uint32_t hardDropFxId{0};
|
||||
uint64_t pieceSequence{0};
|
||||
SoundCallback soundCallback;
|
||||
LevelUpCallback levelUpCallback;
|
||||
|
||||
// Helpers ---------------------------------------------------------------
|
||||
PlayerState& player(PlayerSide s) { return s == PlayerSide::Left ? left : right; }
|
||||
const PlayerState& player(PlayerSide s) const { return s == PlayerSide::Left ? left : right; }
|
||||
|
||||
void refillBag(PlayerState& ps);
|
||||
Piece drawFromBag(PlayerState& ps);
|
||||
void spawn(PlayerState& ps);
|
||||
bool collides(const PlayerState& ps, const Piece& p) const;
|
||||
bool tryMove(PlayerState& ps, int dx, int dy);
|
||||
void lock(PlayerState& ps);
|
||||
void findCompletedLines();
|
||||
void clearLinesInternal();
|
||||
void updateRowStates();
|
||||
void applyLineClearRewards(PlayerState& creditPlayer, int cleared);
|
||||
double gravityMsForLevel(int level) const;
|
||||
int columnMin(PlayerSide s) const { return s == PlayerSide::Left ? 0 : 10; }
|
||||
int columnMax(PlayerSide s) const { return s == PlayerSide::Left ? 9 : 19; }
|
||||
};
|
||||
@ -15,7 +15,7 @@ enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
|
||||
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
||||
|
||||
// Game runtime mode
|
||||
enum class GameMode { Endless, Challenge };
|
||||
enum class GameMode { Endless, Cooperate, Challenge };
|
||||
|
||||
// Special obstacle blocks used by Challenge mode
|
||||
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
||||
|
||||
@ -188,10 +188,13 @@ void LineEffect::initAudio() {
|
||||
}
|
||||
}
|
||||
|
||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize) {
|
||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols, int gapPx, int gapAfterCol) {
|
||||
if (rows.empty()) return;
|
||||
|
||||
clearingRows = rows;
|
||||
effectGridCols = std::max(1, gridCols);
|
||||
effectGapPx = std::max(0, gapPx);
|
||||
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||
state = AnimationState::FLASH_WHITE;
|
||||
timer = 0.0f;
|
||||
dropProgress = 0.0f;
|
||||
@ -228,8 +231,11 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
|
||||
|
||||
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
|
||||
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
|
||||
for (int col = 0; col < Game::COLS; ++col) {
|
||||
for (int col = 0; col < effectGridCols; ++col) {
|
||||
float centerX = gridX + col * blockSize + blockSize * 0.5f;
|
||||
if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) {
|
||||
centerX += static_cast<float>(effectGapPx);
|
||||
}
|
||||
SDL_Color tint = pickFireColor();
|
||||
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
|
||||
spawnShardBurst(centerX, centerY, tint);
|
||||
@ -337,9 +343,13 @@ void LineEffect::updateGlowPulses(float dt) {
|
||||
glowPulses.end());
|
||||
}
|
||||
|
||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) {
|
||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx, int gapAfterCol) {
|
||||
if (state == AnimationState::IDLE) return;
|
||||
|
||||
// Allow caller to override gap mapping (useful for Coop renderer that inserts a mid-gap).
|
||||
effectGapPx = std::max(0, gapPx);
|
||||
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||
|
||||
switch (state) {
|
||||
case AnimationState::FLASH_WHITE:
|
||||
renderFlash(gridX, gridY, blockSize);
|
||||
@ -383,10 +393,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
|
||||
|
||||
for (int row : clearingRows) {
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
|
||||
const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0;
|
||||
SDL_FRect flashRect = {
|
||||
static_cast<float>(gridX - 4),
|
||||
static_cast<float>(gridY + row * blockSize - 4),
|
||||
static_cast<float>(10 * blockSize + 8),
|
||||
static_cast<float>(effectGridCols * blockSize + gapW + 8),
|
||||
static_cast<float>(blockSize + 8)
|
||||
};
|
||||
SDL_RenderFillRect(renderer, &flashRect);
|
||||
|
||||
@ -69,11 +69,11 @@ public:
|
||||
void shutdown();
|
||||
|
||||
// Start line clear effect for the specified rows
|
||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize);
|
||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS, int gapPx = 0, int gapAfterCol = 0);
|
||||
|
||||
// Update and render the effect
|
||||
bool update(float deltaTime); // Returns true if effect is complete
|
||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize);
|
||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx = 0, int gapAfterCol = 0);
|
||||
float getRowDropOffset(int row) const;
|
||||
|
||||
// Audio
|
||||
@ -120,4 +120,7 @@ private:
|
||||
std::array<float, Game::ROWS> rowDropTargets{};
|
||||
float dropProgress = 0.0f;
|
||||
int dropBlockSize = 0;
|
||||
int effectGridCols = Game::COLS;
|
||||
int effectGapPx = 0;
|
||||
int effectGapAfterCol = 0;
|
||||
};
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
#include "GameRenderer.h"
|
||||
|
||||
#include "SyncLineRenderer.h"
|
||||
#include "../../gameplay/core/Game.h"
|
||||
#include "../../gameplay/coop/CoopGame.h"
|
||||
#include "../../app/Fireworks.h"
|
||||
#include "../ui/Font.h"
|
||||
#include "../../gameplay/effects/LineEffect.h"
|
||||
#include <algorithm>
|
||||
@ -693,6 +697,11 @@ void GameRenderer::renderPlayingState(
|
||||
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||
auto completedLines = game->getCompletedLines();
|
||||
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
||||
// Trigger fireworks visually for a 4-line clear (TETRIS)
|
||||
if (completedLines.size() == 4) {
|
||||
// spawn near center of grid
|
||||
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw game grid border
|
||||
@ -1356,6 +1365,26 @@ void GameRenderer::renderPlayingState(
|
||||
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
|
||||
}
|
||||
|
||||
// Debug: log single-player smoothing/fall values when enabled
|
||||
if (Settings::instance().isDebugEnabled()) {
|
||||
float sp_targetX = static_cast<float>(game->current().x);
|
||||
double sp_gravityMs = game->getGravityMs();
|
||||
double sp_fallAcc = game->getFallAccumulator();
|
||||
int sp_soft = game->isSoftDropping() ? 1 : 0;
|
||||
/*
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
|
||||
(unsigned long long)s_activePieceSmooth.sequence,
|
||||
s_activePieceSmooth.visualX,
|
||||
sp_targetX,
|
||||
activePiecePixelOffsetX,
|
||||
activePiecePixelOffsetY,
|
||||
sp_gravityMs,
|
||||
sp_fallAcc,
|
||||
sp_soft
|
||||
);
|
||||
*/
|
||||
}
|
||||
|
||||
// Draw ghost piece (where current piece will land)
|
||||
if (allowActivePieceRender) {
|
||||
Game::Piece ghostPiece = game->current();
|
||||
@ -1806,6 +1835,929 @@ void GameRenderer::renderPlayingState(
|
||||
// Exit popup logic moved to renderExitPopup
|
||||
}
|
||||
|
||||
void GameRenderer::renderCoopPlayingState(
|
||||
SDL_Renderer* renderer,
|
||||
CoopGame* game,
|
||||
FontAtlas* pixelFont,
|
||||
LineEffect* lineEffect,
|
||||
SDL_Texture* blocksTex,
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
bool paused,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
float winW,
|
||||
float winH
|
||||
) {
|
||||
if (!renderer || !game || !pixelFont) return;
|
||||
|
||||
static SyncLineRenderer s_syncLine;
|
||||
static bool s_lastHadCompletedLines = false;
|
||||
|
||||
static Uint32 s_lastCoopTick = SDL_GetTicks();
|
||||
Uint32 nowTicks = SDL_GetTicks();
|
||||
float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
|
||||
s_lastCoopTick = nowTicks;
|
||||
if (deltaMs < 0.0f || deltaMs > 100.0f) {
|
||||
deltaMs = 16.0f;
|
||||
}
|
||||
|
||||
const float deltaSeconds = std::clamp(deltaMs / 1000.0f, 0.0f, 0.033f);
|
||||
s_syncLine.Update(deltaSeconds);
|
||||
|
||||
const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
|
||||
struct SmoothState { bool initialized{false}; uint64_t seq{0}; float visualX{0.0f}; float visualY{0.0f}; };
|
||||
static SmoothState s_leftSmooth{};
|
||||
static SmoothState s_rightSmooth{};
|
||||
struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; int spawnY{0}; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; };
|
||||
static SpawnFadeState s_leftSpawnFade{};
|
||||
static SpawnFadeState s_rightSpawnFade{};
|
||||
|
||||
// Layout constants (reuse single-player feel but sized for 20 cols)
|
||||
const float MIN_MARGIN = 40.0f;
|
||||
const float TOP_MARGIN = 60.0f;
|
||||
const float PANEL_WIDTH = 180.0f;
|
||||
const float PANEL_SPACING = 30.0f;
|
||||
const float NEXT_PANEL_HEIGHT = 120.0f;
|
||||
const float BOTTOM_MARGIN = 60.0f;
|
||||
|
||||
// Content offset (centered logical viewport inside window)
|
||||
float contentScale = logicalScale;
|
||||
float contentW = logicalW * contentScale;
|
||||
float contentH = logicalH * contentScale;
|
||||
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
|
||||
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
|
||||
|
||||
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
|
||||
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
|
||||
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
};
|
||||
|
||||
static constexpr float COOP_GAP_PX = 20.0f;
|
||||
|
||||
const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
|
||||
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PANEL_HEIGHT;
|
||||
|
||||
const float usableGridWidth = std::max(0.0f, availableWidth - COOP_GAP_PX);
|
||||
const float maxBlockSizeW = usableGridWidth / CoopGame::COLS;
|
||||
const float maxBlockSizeH = availableHeight / CoopGame::ROWS;
|
||||
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
|
||||
const float finalBlockSize = std::max(16.0f, std::min(BLOCK_SIZE, 36.0f));
|
||||
|
||||
const float HALF_W = 10.0f * finalBlockSize;
|
||||
const float GRID_W = CoopGame::COLS * finalBlockSize + COOP_GAP_PX;
|
||||
const float GRID_H = CoopGame::ROWS * finalBlockSize;
|
||||
|
||||
const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
|
||||
const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
|
||||
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
|
||||
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
|
||||
|
||||
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
|
||||
const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
|
||||
|
||||
const float statsX = layoutStartX + contentOffsetX;
|
||||
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
|
||||
const float gridY = contentStartY + NEXT_PANEL_HEIGHT + contentOffsetY;
|
||||
|
||||
const float rightPanelX = gridX + GRID_W + PANEL_SPACING;
|
||||
|
||||
const float statsY = gridY;
|
||||
const float statsW = PANEL_WIDTH;
|
||||
const float statsH = GRID_H;
|
||||
|
||||
// (Score panels are drawn per-player below using scorePanelTex and classic sizing.)
|
||||
|
||||
// Handle line clearing effects (defer to LineEffect like single-player)
|
||||
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||
auto completedLines = game->getCompletedLines();
|
||||
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), CoopGame::COLS, static_cast<int>(COOP_GAP_PX), 10);
|
||||
if (completedLines.size() == 4) {
|
||||
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
// Precompute row drop offsets (line collapse effect)
|
||||
std::array<float, CoopGame::ROWS> rowDropOffsets{};
|
||||
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||
rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
|
||||
}
|
||||
|
||||
// Grid backdrop and border (one border around both halves)
|
||||
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
|
||||
// Background for left+right halves
|
||||
drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
|
||||
// Gap background (slightly darker so the 10px separation reads clearly)
|
||||
drawRectWithOffset(gridX + HALF_W - contentOffsetX, gridY - contentOffsetY, COOP_GAP_PX, GRID_H, {12, 14, 18, 255});
|
||||
|
||||
// Sync divider line centered in the gap between halves.
|
||||
const float dividerCenterX = gridX + HALF_W + (COOP_GAP_PX * 0.5f);
|
||||
s_syncLine.SetRect(SDL_FRect{ dividerCenterX - 2.0f, gridY, 4.0f, GRID_H });
|
||||
|
||||
auto cellX = [&](int col) -> float {
|
||||
float x = gridX + col * finalBlockSize;
|
||||
if (col >= 10) {
|
||||
x += COOP_GAP_PX;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
// Grid lines (draw per-half so the gap is clean)
|
||||
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
|
||||
for (int x = 1; x < 10; ++x) {
|
||||
float lineX = gridX + x * finalBlockSize;
|
||||
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
|
||||
}
|
||||
for (int x = 1; x < 10; ++x) {
|
||||
float lineX = gridX + HALF_W + COOP_GAP_PX + x * finalBlockSize;
|
||||
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
|
||||
}
|
||||
for (int y = 1; y < CoopGame::ROWS; ++y) {
|
||||
float lineY = gridY + y * finalBlockSize;
|
||||
SDL_RenderLine(renderer, gridX, lineY, gridX + HALF_W, lineY);
|
||||
SDL_RenderLine(renderer, gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY);
|
||||
}
|
||||
|
||||
// In-grid 3D starfield + ambient sparkles (match classic feel, per-half)
|
||||
{
|
||||
static Uint32 s_lastCoopSparkTick = SDL_GetTicks();
|
||||
static std::mt19937 s_coopSparkRng{ std::random_device{}() };
|
||||
static std::vector<Sparkle> s_leftSparkles;
|
||||
static std::vector<Sparkle> s_rightSparkles;
|
||||
static std::vector<ImpactSpark> s_leftImpactSparks;
|
||||
static std::vector<ImpactSpark> s_rightImpactSparks;
|
||||
static float s_leftSparkleSpawnAcc = 0.0f;
|
||||
static float s_rightSparkleSpawnAcc = 0.0f;
|
||||
|
||||
float halfW = HALF_W;
|
||||
const float leftGridX = gridX;
|
||||
const float rightGridX = gridX + HALF_W + COOP_GAP_PX;
|
||||
|
||||
Uint32 sparkNow = nowTicks;
|
||||
float sparkDeltaMs = static_cast<float>(sparkNow - s_lastCoopSparkTick);
|
||||
s_lastCoopSparkTick = sparkNow;
|
||||
if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) {
|
||||
sparkDeltaMs = 16.0f;
|
||||
}
|
||||
|
||||
if (!s_starfieldInitialized) {
|
||||
s_inGridStarfield.init(static_cast<int>(halfW), static_cast<int>(GRID_H), 180);
|
||||
s_starfieldInitialized = true;
|
||||
} else {
|
||||
s_inGridStarfield.resize(static_cast<int>(halfW), static_cast<int>(GRID_H));
|
||||
}
|
||||
|
||||
const float deltaSeconds = std::clamp(sparkDeltaMs / 1000.0f, 0.0f, 0.033f);
|
||||
s_inGridStarfield.update(deltaSeconds);
|
||||
|
||||
struct MagnetInfo { bool active{false}; float x{0.0f}; float y{0.0f}; };
|
||||
auto computeMagnet = [&](CoopGame::PlayerSide side) -> MagnetInfo {
|
||||
MagnetInfo info{};
|
||||
const CoopGame::Piece& activePiece = game->current(side);
|
||||
const int pieceType = static_cast<int>(activePiece.type);
|
||||
if (pieceType < 0 || pieceType >= PIECE_COUNT) {
|
||||
return info;
|
||||
}
|
||||
|
||||
float sumLocalX = 0.0f;
|
||||
float sumLocalY = 0.0f;
|
||||
int filledCells = 0;
|
||||
const int localXOffsetCols = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(activePiece, cx, cy)) continue;
|
||||
sumLocalX += ((activePiece.x - localXOffsetCols) + cx + 0.5f) * finalBlockSize;
|
||||
sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize;
|
||||
++filledCells;
|
||||
}
|
||||
}
|
||||
if (filledCells <= 0) {
|
||||
return info;
|
||||
}
|
||||
|
||||
info.active = true;
|
||||
info.x = std::clamp(sumLocalX / static_cast<float>(filledCells), 0.0f, halfW);
|
||||
info.y = std::clamp(sumLocalY / static_cast<float>(filledCells), 0.0f, GRID_H);
|
||||
return info;
|
||||
};
|
||||
|
||||
const MagnetInfo leftMagnet = computeMagnet(CoopGame::PlayerSide::Left);
|
||||
const MagnetInfo rightMagnet = computeMagnet(CoopGame::PlayerSide::Right);
|
||||
|
||||
SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
auto drawStarfieldHalf = [&](float originX, const MagnetInfo& magnet) {
|
||||
if (magnet.active) {
|
||||
const float magnetStrength = finalBlockSize * 2.2f;
|
||||
s_inGridStarfield.setMagnetTarget(magnet.x, magnet.y, magnetStrength);
|
||||
} else {
|
||||
s_inGridStarfield.clearMagnetTarget();
|
||||
}
|
||||
|
||||
const float jitterAmp = 1.6f;
|
||||
const float tms = static_cast<float>(sparkNow) * 0.001f;
|
||||
const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f;
|
||||
const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f);
|
||||
s_inGridStarfield.draw(renderer, originX + jitterX, gridY + jitterY, 0.22f, true);
|
||||
};
|
||||
|
||||
drawStarfieldHalf(leftGridX, leftMagnet);
|
||||
drawStarfieldHalf(rightGridX, rightMagnet);
|
||||
|
||||
auto updateAndDrawSparkleLayer = [&](std::vector<Sparkle>& sparkles,
|
||||
std::vector<ImpactSpark>& impactSparks,
|
||||
float& spawnAcc,
|
||||
const MagnetInfo& magnet,
|
||||
float originX) {
|
||||
if (!paused) {
|
||||
const float spawnInterval = 0.08f;
|
||||
spawnAcc += deltaSeconds;
|
||||
while (spawnAcc >= spawnInterval) {
|
||||
spawnAcc -= spawnInterval;
|
||||
|
||||
Sparkle s;
|
||||
bool spawnNearPiece = magnet.active && (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) > 0.35f);
|
||||
|
||||
float sx = 0.0f;
|
||||
float sy = 0.0f;
|
||||
if (spawnNearPiece) {
|
||||
float jitterX = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
|
||||
float jitterY = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
|
||||
sx = std::clamp(magnet.x + jitterX, -finalBlockSize * 2.0f, halfW + finalBlockSize * 2.0f);
|
||||
sy = std::clamp(magnet.y + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f);
|
||||
} else {
|
||||
float side = std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng);
|
||||
const float borderBand = std::max(12.0f, finalBlockSize * 1.0f);
|
||||
if (side < 0.2f) {
|
||||
sx = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
|
||||
sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
|
||||
} else if (side < 0.4f) {
|
||||
sx = std::uniform_real_distribution<float>(halfW, halfW + borderBand)(s_coopSparkRng);
|
||||
sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
|
||||
} else if (side < 0.6f) {
|
||||
sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
|
||||
sy = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
|
||||
} else if (side < 0.9f) {
|
||||
sx = std::uniform_real_distribution<float>(0.0f, halfW)(s_coopSparkRng);
|
||||
sy = std::uniform_real_distribution<float>(0.0f, finalBlockSize * 2.0f)(s_coopSparkRng);
|
||||
} else {
|
||||
sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
|
||||
sy = std::uniform_real_distribution<float>(GRID_H, GRID_H + borderBand)(s_coopSparkRng);
|
||||
}
|
||||
}
|
||||
|
||||
s.x = sx;
|
||||
s.y = sy;
|
||||
float speed = std::uniform_real_distribution<float>(10.0f, 60.0f)(s_coopSparkRng);
|
||||
float ang = std::uniform_real_distribution<float>(-3.14159f, 3.14159f)(s_coopSparkRng);
|
||||
s.vx = std::cos(ang) * speed;
|
||||
s.vy = std::sin(ang) * speed * 0.25f;
|
||||
s.maxLifeMs = std::uniform_real_distribution<float>(350.0f, 900.0f)(s_coopSparkRng);
|
||||
s.lifeMs = s.maxLifeMs;
|
||||
s.size = std::uniform_real_distribution<float>(1.5f, 5.0f)(s_coopSparkRng);
|
||||
if (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) < 0.5f) {
|
||||
s.color = SDL_Color{255, 230, 180, 255};
|
||||
} else {
|
||||
s.color = SDL_Color{180, 220, 255, 255};
|
||||
}
|
||||
s.pulse = std::uniform_real_distribution<float>(0.0f, 6.28f)(s_coopSparkRng);
|
||||
sparkles.push_back(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sparkles.empty()) {
|
||||
auto it = sparkles.begin();
|
||||
while (it != sparkles.end()) {
|
||||
Sparkle& sp = *it;
|
||||
sp.lifeMs -= sparkDeltaMs;
|
||||
if (sp.lifeMs <= 0.0f) {
|
||||
const int burstCount = std::uniform_int_distribution<int>(4, 8)(s_coopSparkRng);
|
||||
for (int bi = 0; bi < burstCount; ++bi) {
|
||||
ImpactSpark ps;
|
||||
ps.x = originX + sp.x + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
|
||||
ps.y = gridY + sp.y + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
|
||||
float ang = std::uniform_real_distribution<float>(0.0f, 6.2831853f)(s_coopSparkRng);
|
||||
float speed = std::uniform_real_distribution<float>(10.0f, 120.0f)(s_coopSparkRng);
|
||||
ps.vx = std::cos(ang) * speed;
|
||||
ps.vy = std::sin(ang) * speed * 0.8f;
|
||||
ps.maxLifeMs = std::uniform_real_distribution<float>(220.0f, 500.0f)(s_coopSparkRng);
|
||||
ps.lifeMs = ps.maxLifeMs;
|
||||
ps.size = std::max(1.0f, sp.size * 0.5f);
|
||||
ps.color = sp.color;
|
||||
impactSparks.push_back(ps);
|
||||
}
|
||||
it = sparkles.erase(it);
|
||||
continue;
|
||||
}
|
||||
|
||||
float lifeRatio = sp.lifeMs / sp.maxLifeMs;
|
||||
sp.x += sp.vx * deltaSeconds;
|
||||
sp.y += sp.vy * deltaSeconds;
|
||||
sp.vy *= 0.995f;
|
||||
sp.pulse += deltaSeconds * 8.0f;
|
||||
|
||||
float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
|
||||
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
|
||||
SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha);
|
||||
float half = sp.size * 0.5f;
|
||||
SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size };
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
if (!impactSparks.empty()) {
|
||||
auto it = impactSparks.begin();
|
||||
while (it != impactSparks.end()) {
|
||||
ImpactSpark& spark = *it;
|
||||
spark.vy += 0.00045f * sparkDeltaMs;
|
||||
spark.x += spark.vx * sparkDeltaMs;
|
||||
spark.y += spark.vy * sparkDeltaMs;
|
||||
spark.lifeMs -= sparkDeltaMs;
|
||||
if (spark.lifeMs <= 0.0f) {
|
||||
it = impactSparks.erase(it);
|
||||
continue;
|
||||
}
|
||||
float lifeRatio = spark.lifeMs / spark.maxLifeMs;
|
||||
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
|
||||
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha);
|
||||
SDL_FRect sparkRect{
|
||||
spark.x - spark.size * 0.5f,
|
||||
spark.y - spark.size * 0.5f,
|
||||
spark.size,
|
||||
spark.size * 1.4f
|
||||
};
|
||||
SDL_RenderFillRect(renderer, &sparkRect);
|
||||
++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateAndDrawSparkleLayer(s_leftSparkles, s_leftImpactSparks, s_leftSparkleSpawnAcc, leftMagnet, leftGridX);
|
||||
updateAndDrawSparkleLayer(s_rightSparkles, s_rightImpactSparks, s_rightSparkleSpawnAcc, rightMagnet, rightGridX);
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
|
||||
// Half-row feedback: lightly tint rows where one side is filled, brighter where both are pending clear
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
const auto& rowStates = game->rowHalfStates();
|
||||
|
||||
bool leftReady = false;
|
||||
bool rightReady = false;
|
||||
bool synced = false;
|
||||
|
||||
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||
const auto& rs = rowStates[y];
|
||||
float rowY = gridY + y * finalBlockSize;
|
||||
|
||||
if (rs.leftFull && rs.rightFull) {
|
||||
synced = true;
|
||||
} else {
|
||||
leftReady = leftReady || (rs.leftFull && !rs.rightFull);
|
||||
rightReady = rightReady || (rs.rightFull && !rs.leftFull);
|
||||
}
|
||||
|
||||
if (rs.leftFull && rs.rightFull) {
|
||||
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45);
|
||||
SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize};
|
||||
SDL_RenderFillRect(renderer, &frL);
|
||||
SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize};
|
||||
SDL_RenderFillRect(renderer, &frR);
|
||||
} else if (rs.leftFull ^ rs.rightFull) {
|
||||
SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35);
|
||||
float w = HALF_W;
|
||||
float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX);
|
||||
SDL_FRect fr{x, rowY, w, finalBlockSize};
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
}
|
||||
}
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||
|
||||
// Trigger a brief flash exactly when cooperative lines are actually cleared:
|
||||
// `completedLines` remains populated during the LineEffect, then becomes empty
|
||||
// immediately after `CoopGame::clearCompletedLines()` is invoked.
|
||||
const bool hasCompletedLines = game->hasCompletedLines();
|
||||
if (s_lastHadCompletedLines && !hasCompletedLines) {
|
||||
s_syncLine.TriggerClearFlash();
|
||||
}
|
||||
s_lastHadCompletedLines = hasCompletedLines;
|
||||
|
||||
if (synced) {
|
||||
s_syncLine.SetState(SyncState::Synced);
|
||||
} else if (leftReady) {
|
||||
s_syncLine.SetState(SyncState::LeftReady);
|
||||
} else if (rightReady) {
|
||||
s_syncLine.SetState(SyncState::RightReady);
|
||||
} else {
|
||||
s_syncLine.SetState(SyncState::Idle);
|
||||
}
|
||||
|
||||
// Hard-drop impact shake (match classic feel)
|
||||
float impactStrength = 0.0f;
|
||||
float impactEased = 0.0f;
|
||||
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> impactMask{};
|
||||
std::array<float, CoopGame::COLS * CoopGame::ROWS> impactWeight{};
|
||||
if (game->hasHardDropShake()) {
|
||||
impactStrength = static_cast<float>(game->hardDropShakeStrength());
|
||||
impactStrength = std::clamp(impactStrength, 0.0f, 1.0f);
|
||||
impactEased = impactStrength * impactStrength;
|
||||
const auto& impactCells = game->getHardDropCells();
|
||||
const auto& boardRef = game->boardRef();
|
||||
for (const auto& cell : impactCells) {
|
||||
if (cell.x < 0 || cell.x >= CoopGame::COLS || cell.y < 0 || cell.y >= CoopGame::ROWS) {
|
||||
continue;
|
||||
}
|
||||
int idx = cell.y * CoopGame::COLS + cell.x;
|
||||
impactMask[idx] = 1;
|
||||
impactWeight[idx] = 1.0f;
|
||||
|
||||
int depth = 0;
|
||||
for (int ny = cell.y + 1; ny < CoopGame::ROWS && depth < 4; ++ny) {
|
||||
if (!boardRef[ny * CoopGame::COLS + cell.x].occupied) {
|
||||
break;
|
||||
}
|
||||
++depth;
|
||||
int nidx = ny * CoopGame::COLS + cell.x;
|
||||
impactMask[nidx] = 1;
|
||||
float weight = std::max(0.15f, 1.0f - depth * 0.35f);
|
||||
impactWeight[nidx] = std::max(impactWeight[nidx], weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw settled blocks
|
||||
const auto& board = game->boardRef();
|
||||
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||
float dropOffset = rowDropOffsets[y];
|
||||
for (int x = 0; x < CoopGame::COLS; ++x) {
|
||||
const auto& cell = board[y * CoopGame::COLS + x];
|
||||
if (!cell.occupied || cell.value <= 0) continue;
|
||||
float px = cellX(x);
|
||||
float py = gridY + y * finalBlockSize + dropOffset;
|
||||
|
||||
const int cellIdx = y * CoopGame::COLS + x;
|
||||
float weight = impactWeight[cellIdx];
|
||||
if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) {
|
||||
float cellSeed = static_cast<float>((x * 37 + y * 61) % 113);
|
||||
float t = static_cast<float>(nowTicks % 10000) * 0.018f + cellSeed;
|
||||
float amplitude = 6.0f * impactEased * weight;
|
||||
float freq = 2.0f + weight * 1.3f;
|
||||
px += amplitude * std::sin(t * freq);
|
||||
py += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
|
||||
}
|
||||
|
||||
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Active pieces (per-side smoothing)
|
||||
auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) {
|
||||
float offsetX = 0.0f;
|
||||
float offsetY = 0.0f;
|
||||
|
||||
if (smoothScrollEnabled) {
|
||||
const uint64_t seq = game->currentPieceSequence(side);
|
||||
const float targetX = static_cast<float>(game->current(side).x);
|
||||
if (!ss.initialized || ss.seq != seq) {
|
||||
ss.initialized = true;
|
||||
ss.seq = seq;
|
||||
ss.visualX = targetX;
|
||||
// Trigger a short spawn fade so the newly spawned piece visually
|
||||
// fades into the first visible row (like classic mode).
|
||||
SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade;
|
||||
sf.active = true;
|
||||
sf.startTick = nowTicks;
|
||||
sf.durationMs = 200.0f;
|
||||
sf.seq = seq;
|
||||
sf.piece = game->current(side);
|
||||
sf.spawnY = sf.piece.y;
|
||||
sf.tileSize = finalBlockSize;
|
||||
// Note: during the spawn fade we draw the live piece each frame.
|
||||
// If the piece is still above the visible grid, we temporarily pin
|
||||
// it so the topmost filled cell appears at row 0 (no spawn delay),
|
||||
// while still applying smoothing offsets so it starts moving
|
||||
// immediately.
|
||||
sf.targetX = 0.0f;
|
||||
sf.targetY = 0.0f;
|
||||
} else {
|
||||
// Reuse exact horizontal smoothing from single-player
|
||||
constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
|
||||
const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
|
||||
ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor);
|
||||
}
|
||||
offsetX = (ss.visualX - targetX) * finalBlockSize;
|
||||
|
||||
// Reuse exact single-player fall offset computation (per-side getters)
|
||||
double gravityMs = game->getGravityMs();
|
||||
if (gravityMs > 0.0) {
|
||||
double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||
double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs);
|
||||
float progress = static_cast<float>(accumulator / effectiveMs);
|
||||
progress = std::clamp(progress, 0.0f, 1.0f);
|
||||
offsetY = progress * finalBlockSize;
|
||||
|
||||
// Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player)
|
||||
const auto& boardRef = game->boardRef();
|
||||
const CoopGame::Piece& piece = game->current(side);
|
||||
float maxAllowed = finalBlockSize;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||
int gx = piece.x + cx;
|
||||
int gy = piece.y + cy;
|
||||
if (gx < 0 || gx >= CoopGame::COLS) continue;
|
||||
int testY = gy + 1;
|
||||
int emptyRows = 0;
|
||||
if (testY < 0) {
|
||||
emptyRows -= testY;
|
||||
testY = 0;
|
||||
}
|
||||
while (testY >= 0 && testY < CoopGame::ROWS) {
|
||||
if (boardRef[testY * CoopGame::COLS + gx].occupied) break;
|
||||
++emptyRows;
|
||||
++testY;
|
||||
}
|
||||
float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
|
||||
maxAllowed = std::min(maxAllowed, cellLimit);
|
||||
}
|
||||
}
|
||||
offsetY = std::min(offsetY, maxAllowed);
|
||||
}
|
||||
} else {
|
||||
ss.initialized = true;
|
||||
ss.seq = game->currentPieceSequence(side);
|
||||
ss.visualX = static_cast<float>(game->current(side).x);
|
||||
}
|
||||
|
||||
if (Settings::instance().isDebugEnabled()) {
|
||||
float dbg_targetX = static_cast<float>(game->current(side).x);
|
||||
double gMsDbg = game->getGravityMs();
|
||||
double accDbg = game->getFallAccumulator(side);
|
||||
int softDbg = game->isSoftDropping(side) ? 1 : 0;
|
||||
/*
|
||||
SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
|
||||
(side == CoopGame::PlayerSide::Left) ? "L" : "R",
|
||||
(unsigned long long)ss.seq,
|
||||
ss.visualX,
|
||||
dbg_targetX,
|
||||
offsetX,
|
||||
offsetY,
|
||||
gMsDbg,
|
||||
accDbg,
|
||||
softDbg
|
||||
);
|
||||
*/
|
||||
}
|
||||
return std::pair<float, float>{ offsetX, offsetY };
|
||||
};
|
||||
|
||||
auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair<float, float>& offsets) {
|
||||
if (!sf.active) return;
|
||||
|
||||
// If the piece has already changed, stop the fade.
|
||||
const uint64_t currentSeq = game->currentPieceSequence(side);
|
||||
if (sf.seq != currentSeq) {
|
||||
sf.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const CoopGame::Piece& livePiece = game->current(side);
|
||||
float elapsed = static_cast<float>(nowTicks - sf.startTick);
|
||||
float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
|
||||
Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
|
||||
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha);
|
||||
|
||||
int minCy = 4;
|
||||
int maxCy = -1;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
|
||||
minCy = std::min(minCy, cy);
|
||||
maxCy = std::max(maxCy, cy);
|
||||
}
|
||||
}
|
||||
if (minCy == 4) {
|
||||
minCy = 0;
|
||||
}
|
||||
if (maxCy < 0) {
|
||||
maxCy = 0;
|
||||
}
|
||||
|
||||
// Pin only when *no* filled cell is visible yet. Using maxCy avoids pinning
|
||||
// cases like vertical I where some blocks are already visible at spawn.
|
||||
const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0;
|
||||
|
||||
const float baseX = cellX(livePiece.x) + offsets.first;
|
||||
float baseY = 0.0f;
|
||||
if (pinToFirstVisibleRow) {
|
||||
// Keep the piece visible (topmost filled cell at row 0), but also
|
||||
// incorporate real y-step progression so the fall accumulator wrapping
|
||||
// doesn't produce a one-row snap.
|
||||
const int dySteps = livePiece.y - sf.spawnY;
|
||||
baseY = (gridY - static_cast<float>(minCy) * sf.tileSize)
|
||||
+ static_cast<float>(dySteps) * sf.tileSize
|
||||
+ offsets.second;
|
||||
} else {
|
||||
baseY = gridY + static_cast<float>(livePiece.y) * sf.tileSize + offsets.second;
|
||||
}
|
||||
|
||||
// Draw the live piece (either pinned-to-row0 or at its real position).
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
|
||||
int pyIdx = livePiece.y + cy;
|
||||
if (!pinToFirstVisibleRow && pyIdx < 0) continue;
|
||||
float px = baseX + static_cast<float>(cx) * sf.tileSize;
|
||||
float py = baseY + static_cast<float>(cy) * sf.tileSize;
|
||||
drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type);
|
||||
}
|
||||
}
|
||||
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
|
||||
|
||||
// End fade after duration, but never stop while we are pinning (otherwise
|
||||
// I can briefly disappear until it becomes visible in the real grid).
|
||||
if (t >= 1.0f && !pinToFirstVisibleRow) {
|
||||
sf.active = false;
|
||||
}
|
||||
};
|
||||
|
||||
auto drawPiece = [&](const CoopGame::Piece& p, const std::pair<float, float>& offsets, bool isGhost) {
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(p, cx, cy)) continue;
|
||||
int pxIdx = p.x + cx;
|
||||
int pyIdx = p.y + cy;
|
||||
if (pyIdx < 0) continue; // don't draw parts above the visible grid
|
||||
float px = cellX(pxIdx) + offsets.first;
|
||||
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
|
||||
if (isGhost) {
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20);
|
||||
SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
|
||||
SDL_RenderFillRect(renderer, &rect);
|
||||
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30);
|
||||
SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
|
||||
SDL_RenderRect(renderer, &border);
|
||||
} else {
|
||||
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth);
|
||||
const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth);
|
||||
// Draw transient spawn fades (if active)
|
||||
drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left, leftOffsets);
|
||||
drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right, rightOffsets);
|
||||
|
||||
// Draw classic-style ghost pieces (landing position), grid-aligned.
|
||||
// This intentionally does NOT use smoothing offsets.
|
||||
auto computeGhostPiece = [&](CoopGame::PlayerSide side) {
|
||||
CoopGame::Piece ghostPiece = game->current(side);
|
||||
const auto& boardRef = game->boardRef();
|
||||
while (true) {
|
||||
CoopGame::Piece testPiece = ghostPiece;
|
||||
testPiece.y++;
|
||||
bool collision = false;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(testPiece, cx, cy)) continue;
|
||||
int gx = testPiece.x + cx;
|
||||
int gy = testPiece.y + cy;
|
||||
if (gy >= CoopGame::ROWS || gx < 0 || gx >= CoopGame::COLS ||
|
||||
(gy >= 0 && boardRef[gy * CoopGame::COLS + gx].occupied)) {
|
||||
collision = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collision) break;
|
||||
}
|
||||
if (collision) break;
|
||||
ghostPiece = testPiece;
|
||||
}
|
||||
return ghostPiece;
|
||||
};
|
||||
|
||||
const std::pair<float, float> ghostOffsets{0.0f, 0.0f};
|
||||
drawPiece(computeGhostPiece(CoopGame::PlayerSide::Left), ghostOffsets, true);
|
||||
drawPiece(computeGhostPiece(CoopGame::PlayerSide::Right), ghostOffsets, true);
|
||||
|
||||
// If a spawn fade is active for a side and matches the current piece
|
||||
// sequence, only draw the fade visual and skip the regular piece draw
|
||||
// to avoid a double-draw that appears as a jump when falling starts.
|
||||
if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) {
|
||||
drawPiece(game->current(CoopGame::PlayerSide::Left), leftOffsets, false);
|
||||
}
|
||||
if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) {
|
||||
drawPiece(game->current(CoopGame::PlayerSide::Right), rightOffsets, false);
|
||||
}
|
||||
|
||||
// Draw line clearing effects above pieces (matches single-player)
|
||||
if (lineEffect && lineEffect->isActive()) {
|
||||
lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), static_cast<int>(COOP_GAP_PX), 10);
|
||||
}
|
||||
|
||||
// Render the SYNC divider last so it stays visible above effects/blocks.
|
||||
s_syncLine.Render(renderer);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||
|
||||
// Next panels (two)
|
||||
const float nextPanelPad = 12.0f;
|
||||
const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f;
|
||||
const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
|
||||
float nextLeftX = gridX + finalBlockSize;
|
||||
float nextRightX = gridX + HALF_W + COOP_GAP_PX + (HALF_W - finalBlockSize - nextPanelW);
|
||||
float nextY = contentStartY + contentOffsetY;
|
||||
|
||||
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
|
||||
SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
|
||||
if (nextPanelTex) {
|
||||
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel);
|
||||
} else {
|
||||
drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
|
||||
}
|
||||
// Center piece inside panel
|
||||
int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||
minCx = std::min(minCx, cx);
|
||||
minCy = std::min(minCy, cy);
|
||||
maxCx = std::max(maxCx, cx);
|
||||
maxCy = std::max(maxCy, cy);
|
||||
}
|
||||
}
|
||||
if (maxCx >= minCx && maxCy >= minCy) {
|
||||
float tile = finalBlockSize * 0.8f;
|
||||
float pieceW = (maxCx - minCx + 1) * tile;
|
||||
float pieceH = (maxCy - minCy + 1) * tile;
|
||||
float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile;
|
||||
float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||
float px = startX + cx * tile;
|
||||
float py = startY + cy * tile;
|
||||
drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left));
|
||||
drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right));
|
||||
|
||||
// Per-player scoreboards (left and right)
|
||||
auto drawPlayerScoreboard = [&](CoopGame::PlayerSide side, float columnLeftX, float columnRightX, const char* title) {
|
||||
const SDL_Color labelColor{255, 220, 0, 255};
|
||||
const SDL_Color valueColor{255, 255, 255, 255};
|
||||
const SDL_Color nextColor{80, 255, 120, 255};
|
||||
|
||||
// Match classic vertical placement feel
|
||||
const float contentTopOffset = 0.0f;
|
||||
const float contentBottomOffset = 290.0f;
|
||||
const float contentPad = 36.0f;
|
||||
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
|
||||
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
|
||||
|
||||
const float statsPanelPadLeft = 40.0f;
|
||||
const float statsPanelPadRight = 34.0f;
|
||||
const float statsPanelPadY = 28.0f;
|
||||
|
||||
const float textX = columnLeftX + statsPanelPadLeft;
|
||||
|
||||
char scoreStr[32];
|
||||
std::snprintf(scoreStr, sizeof(scoreStr), "%d", game->score(side));
|
||||
|
||||
char linesStr[16];
|
||||
std::snprintf(linesStr, sizeof(linesStr), "%03d", game->lines(side));
|
||||
|
||||
char levelStr[16];
|
||||
std::snprintf(levelStr, sizeof(levelStr), "%02d", game->level(side));
|
||||
|
||||
// Next level progression (per-player lines)
|
||||
int startLv = game->startLevelBase();
|
||||
int linesDone = game->lines(side);
|
||||
int firstThreshold = (startLv + 1) * 10;
|
||||
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);
|
||||
char nextStr[32];
|
||||
std::snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
|
||||
|
||||
// Time display (shared session time)
|
||||
int totalSecs = game->elapsed(side);
|
||||
int mins = totalSecs / 60;
|
||||
int secs = totalSecs % 60;
|
||||
char timeStr[16];
|
||||
std::snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
|
||||
|
||||
struct StatLine {
|
||||
const char* text;
|
||||
float offsetY;
|
||||
float scale;
|
||||
SDL_Color color;
|
||||
};
|
||||
|
||||
// Keep offsets aligned with classic spacing
|
||||
std::vector<StatLine> statLines;
|
||||
statLines.reserve(12);
|
||||
statLines.push_back({title, 0.0f, 0.95f, SDL_Color{200, 220, 235, 220}});
|
||||
statLines.push_back({"SCORE", 30.0f, 1.0f, labelColor});
|
||||
statLines.push_back({scoreStr, 55.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"LINES", 100.0f, 1.0f, labelColor});
|
||||
statLines.push_back({linesStr, 125.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"LEVEL", 170.0f, 1.0f, labelColor});
|
||||
statLines.push_back({levelStr, 195.0f, 0.9f, valueColor});
|
||||
statLines.push_back({"NEXT LVL", 230.0f, 1.0f, labelColor});
|
||||
statLines.push_back({nextStr, 255.0f, 0.9f, nextColor});
|
||||
statLines.push_back({"TIME", 295.0f, 1.0f, labelColor});
|
||||
statLines.push_back({timeStr, 320.0f, 0.9f, valueColor});
|
||||
|
||||
// Size the panel like classic: measure the text block and fit the background.
|
||||
float statsContentTop = std::numeric_limits<float>::max();
|
||||
float statsContentBottom = std::numeric_limits<float>::lowest();
|
||||
float statsContentMaxWidth = 0.0f;
|
||||
for (const auto& line : statLines) {
|
||||
int textW = 0;
|
||||
int textH = 0;
|
||||
pixelFont->measure(line.text, line.scale, textW, textH);
|
||||
float y = baseY + line.offsetY;
|
||||
statsContentTop = std::min(statsContentTop, y);
|
||||
statsContentBottom = std::max(statsContentBottom, y + static_cast<float>(textH));
|
||||
statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast<float>(textW));
|
||||
}
|
||||
|
||||
float panelW = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight;
|
||||
float panelH = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f;
|
||||
float panelY = statsContentTop - statsPanelPadY;
|
||||
// Left player is left-aligned in its column; right player is right-aligned.
|
||||
float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX;
|
||||
SDL_FRect panelBg{ panelX, panelY, panelW, panelH };
|
||||
if (scorePanelTex) {
|
||||
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &panelBg);
|
||||
} else {
|
||||
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205);
|
||||
SDL_RenderFillRect(renderer, &panelBg);
|
||||
}
|
||||
|
||||
float textDrawX = panelX + statsPanelPadLeft;
|
||||
for (const auto& line : statLines) {
|
||||
pixelFont->draw(renderer, textDrawX, baseY + line.offsetY, line.text, line.scale, line.color);
|
||||
}
|
||||
};
|
||||
|
||||
// Nudge panels toward the window edges for tighter symmetry.
|
||||
const float scorePanelEdgeNudge = 20.0f;
|
||||
const float leftColumnLeftX = statsX - scorePanelEdgeNudge;
|
||||
const float leftColumnRightX = leftColumnLeftX + statsW;
|
||||
const float rightColumnLeftX = rightPanelX;
|
||||
const float rightColumnRightX = rightColumnLeftX + statsW + scorePanelEdgeNudge;
|
||||
|
||||
drawPlayerScoreboard(CoopGame::PlayerSide::Left, leftColumnLeftX, leftColumnRightX, "PLAYER 1");
|
||||
drawPlayerScoreboard(CoopGame::PlayerSide::Right, rightColumnLeftX, rightColumnRightX, "PLAYER 2");
|
||||
|
||||
// Combined score summary centered under the grid
|
||||
{
|
||||
int leftScore = game->score(CoopGame::PlayerSide::Left);
|
||||
int rightScore = game->score(CoopGame::PlayerSide::Right);
|
||||
int sumScore = leftScore + rightScore;
|
||||
char sumLabel[64];
|
||||
char sumValue[64];
|
||||
std::snprintf(sumLabel, sizeof(sumLabel), "SCORE %d + SCORE %d =", leftScore, rightScore);
|
||||
std::snprintf(sumValue, sizeof(sumValue), "%d", sumScore);
|
||||
|
||||
// Draw label smaller and value larger
|
||||
float labelScale = 0.9f;
|
||||
float valueScale = 1.6f;
|
||||
SDL_Color labelColor = {200, 220, 235, 220};
|
||||
SDL_Color valueColor = {255, 230, 130, 255};
|
||||
|
||||
// Position: centered beneath the grid
|
||||
float centerX = gridX + GRID_W * 0.5f;
|
||||
int lw=0, lh=0; pixelFont->measure(sumLabel, labelScale, lw, lh);
|
||||
int vw=0, vh=0; pixelFont->measure(sumValue, valueScale, vw, vh);
|
||||
float labelX = centerX - static_cast<float>(lw) * 0.5f;
|
||||
float valueX = centerX - static_cast<float>(vw) * 0.5f;
|
||||
float belowY = gridY + GRID_H + 14.0f; // small gap below grid
|
||||
|
||||
pixelFont->draw(renderer, labelX, belowY, sumLabel, labelScale, labelColor);
|
||||
pixelFont->draw(renderer, valueX, belowY + 22.0f, sumValue, valueScale, valueColor);
|
||||
}
|
||||
}
|
||||
|
||||
void GameRenderer::renderExitPopup(
|
||||
SDL_Renderer* renderer,
|
||||
FontAtlas* pixelFont,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include "../../gameplay/core/Game.h"
|
||||
#include "../../gameplay/coop/CoopGame.h"
|
||||
|
||||
// Forward declarations
|
||||
class FontAtlas;
|
||||
@ -61,6 +62,24 @@ public:
|
||||
int selectedButton
|
||||
);
|
||||
|
||||
static void renderCoopPlayingState(
|
||||
SDL_Renderer* renderer,
|
||||
CoopGame* game,
|
||||
FontAtlas* pixelFont,
|
||||
LineEffect* lineEffect,
|
||||
SDL_Texture* blocksTex,
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
bool paused,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
float winW,
|
||||
float winH
|
||||
);
|
||||
|
||||
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
||||
// calling from non-member helper functions (e.g. visual effects) that cannot
|
||||
// access private class members.
|
||||
|
||||
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
@ -0,0 +1,358 @@
|
||||
#include "SyncLineRenderer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
SyncLineRenderer::SyncLineRenderer()
|
||||
: m_state(SyncState::Idle),
|
||||
m_flashTimer(0.0f),
|
||||
m_time(0.0f) {
|
||||
m_particles.reserve(MAX_PARTICLES);
|
||||
}
|
||||
|
||||
static float syncWobbleX(float t) {
|
||||
// Small, smooth horizontal motion to make the conduit feel fluid.
|
||||
// Kept subtle so it doesn't distract from gameplay.
|
||||
return std::sinf(t * 2.1f) * 1.25f + std::sinf(t * 5.2f + 1.3f) * 0.55f;
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SpawnParticle() {
|
||||
if (m_particles.size() >= MAX_PARTICLES) {
|
||||
return;
|
||||
}
|
||||
|
||||
SyncParticle p;
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||
// Spawn around the beam center so it reads like a conduit.
|
||||
const float jitter = -8.0f + static_cast<float>(std::rand() % 17);
|
||||
|
||||
p.x = centerX + jitter;
|
||||
p.y = m_rect.y + m_rect.h + static_cast<float>(std::rand() % 10);
|
||||
|
||||
// Two styles: tiny sparkle dots + short streaks.
|
||||
const bool dot = (std::rand() % 100) < 35;
|
||||
if (dot) {
|
||||
p.vx = (-18.0f + static_cast<float>(std::rand() % 37));
|
||||
p.vy = 180.0f + static_cast<float>(std::rand() % 180);
|
||||
p.w = 1.0f + static_cast<float>(std::rand() % 2);
|
||||
p.h = 1.0f + static_cast<float>(std::rand() % 2);
|
||||
p.alpha = 240.0f;
|
||||
} else {
|
||||
p.vx = (-14.0f + static_cast<float>(std::rand() % 29));
|
||||
p.vy = 160.0f + static_cast<float>(std::rand() % 200);
|
||||
p.w = 1.0f + static_cast<float>(std::rand() % 3);
|
||||
p.h = 3.0f + static_cast<float>(std::rand() % 10);
|
||||
p.alpha = 220.0f;
|
||||
}
|
||||
|
||||
// Slight color variance (cyan/green/white) to keep it energetic.
|
||||
const int roll = std::rand() % 100;
|
||||
if (roll < 55) {
|
||||
p.color = SDL_Color{110, 255, 210, 255};
|
||||
} else if (roll < 90) {
|
||||
p.color = SDL_Color{120, 210, 255, 255};
|
||||
} else {
|
||||
p.color = SDL_Color{255, 255, 255, 255};
|
||||
}
|
||||
|
||||
m_particles.push_back(p);
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SpawnBurst(int count) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
SpawnParticle();
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SetRect(const SDL_FRect& rect) {
|
||||
m_rect = rect;
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SetState(SyncState state) {
|
||||
if (state != SyncState::ClearFlash) {
|
||||
m_state = state;
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::TriggerClearFlash() {
|
||||
m_state = SyncState::ClearFlash;
|
||||
m_flashTimer = FLASH_DURATION;
|
||||
|
||||
// Reward burst: strong visual feedback on cooperative clear.
|
||||
SpawnBurst(56);
|
||||
}
|
||||
|
||||
void SyncLineRenderer::Update(float deltaTime) {
|
||||
m_time += deltaTime;
|
||||
m_pulseTime += deltaTime;
|
||||
|
||||
// State-driven particle spawning
|
||||
float spawnRatePerSec = 0.0f;
|
||||
int particlesPerSpawn = 1;
|
||||
switch (m_state) {
|
||||
case SyncState::LeftReady:
|
||||
case SyncState::RightReady:
|
||||
spawnRatePerSec = 24.0f; // steady
|
||||
break;
|
||||
case SyncState::Synced:
|
||||
spawnRatePerSec = 78.0f; // very heavy stream
|
||||
particlesPerSpawn = 2;
|
||||
break;
|
||||
default:
|
||||
spawnRatePerSec = 18.0f; // always-on sparkle stream
|
||||
break;
|
||||
}
|
||||
|
||||
if (spawnRatePerSec <= 0.0f) {
|
||||
m_spawnAcc = 0.0f;
|
||||
} else {
|
||||
m_spawnAcc += deltaTime * spawnRatePerSec;
|
||||
while (m_spawnAcc >= 1.0f) {
|
||||
m_spawnAcc -= 1.0f;
|
||||
for (int i = 0; i < particlesPerSpawn; ++i) {
|
||||
SpawnParticle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update particles
|
||||
for (auto& p : m_particles) {
|
||||
p.x += p.vx * deltaTime;
|
||||
p.y -= p.vy * deltaTime;
|
||||
// Slow drift & fade.
|
||||
p.vx *= (1.0f - 0.35f * deltaTime);
|
||||
p.alpha -= 115.0f * deltaTime;
|
||||
}
|
||||
std::erase_if(m_particles, [&](const SyncParticle& p) {
|
||||
// Cull when out of view or too far from the beam.
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||
const float maxDx = 18.0f;
|
||||
return (p.y < (m_rect.y - 16.0f)) || p.alpha <= 0.0f || std::fabs(p.x - centerX) > maxDx;
|
||||
});
|
||||
|
||||
if (m_state == SyncState::ClearFlash) {
|
||||
m_flashTimer -= deltaTime;
|
||||
if (m_flashTimer <= 0.0f) {
|
||||
m_state = SyncState::Idle;
|
||||
m_flashTimer = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Color SyncLineRenderer::GetBaseColor() const {
|
||||
switch (m_state) {
|
||||
case SyncState::LeftReady:
|
||||
case SyncState::RightReady:
|
||||
return SDL_Color{255, 220, 100, 235};
|
||||
|
||||
case SyncState::Synced:
|
||||
return SDL_Color{100, 255, 120, 240};
|
||||
|
||||
case SyncState::ClearFlash:
|
||||
return SDL_Color{255, 255, 255, 255};
|
||||
|
||||
default:
|
||||
return SDL_Color{80, 180, 255, 235};
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::Render(SDL_Renderer* renderer) {
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We render the conduit with lots of translucent layers. Using additive blending
|
||||
// for glow/pulse makes it read like a blurred beam without shaders.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
const float wobbleX = syncWobbleX(m_time);
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + wobbleX;
|
||||
const float h = m_rect.h;
|
||||
const float hotspotH = std::clamp(h * 0.12f, 18.0f, 44.0f);
|
||||
|
||||
// Flash factor (0..1)
|
||||
const float flashT = (m_state == SyncState::ClearFlash && FLASH_DURATION > 0.0f)
|
||||
? std::clamp(m_flashTimer / FLASH_DURATION, 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
|
||||
SDL_Color color = GetBaseColor();
|
||||
|
||||
// Synced pulse drives aura + core intensity.
|
||||
float pulse01 = 0.0f;
|
||||
if (m_state == SyncState::Synced) {
|
||||
pulse01 = 0.5f + 0.5f * std::sinf(m_time * 6.0f);
|
||||
}
|
||||
|
||||
// 1) Outer aura layers (bloom-like using rectangles)
|
||||
auto drawGlow = [&](float extraW, Uint8 a, SDL_Color c) {
|
||||
SDL_FRect fr{
|
||||
centerX - (m_rect.w + extraW) * 0.5f,
|
||||
m_rect.y,
|
||||
m_rect.w + extraW,
|
||||
m_rect.h
|
||||
};
|
||||
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a);
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
};
|
||||
|
||||
SDL_Color aura = color;
|
||||
// Slightly bias aura towards cyan so it reads “energy conduit”.
|
||||
aura.r = static_cast<Uint8>(std::min(255, static_cast<int>(aura.r) + 10));
|
||||
aura.g = static_cast<Uint8>(std::min(255, static_cast<int>(aura.g) + 10));
|
||||
aura.b = static_cast<Uint8>(std::min(255, static_cast<int>(aura.b) + 35));
|
||||
|
||||
const float auraBoost = (m_state == SyncState::Synced) ? (0.70f + 0.80f * pulse01) : 0.70f;
|
||||
const float flashBoost = 1.0f + flashT * 1.45f;
|
||||
|
||||
SDL_BlendMode oldBlend = SDL_BLENDMODE_BLEND;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
|
||||
SDL_Color auraOuter = aura;
|
||||
auraOuter.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.r) + 10));
|
||||
auraOuter.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.g) + 5));
|
||||
auraOuter.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.b) + 55));
|
||||
|
||||
SDL_Color auraInner = aura;
|
||||
auraInner.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.r) + 40));
|
||||
auraInner.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.g) + 40));
|
||||
auraInner.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.b) + 70));
|
||||
|
||||
// Wider + softer outer halo, then tighter inner glow.
|
||||
drawGlow(62.0f, static_cast<Uint8>(std::clamp(12.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(44.0f, static_cast<Uint8>(std::clamp(20.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(30.0f, static_cast<Uint8>(std::clamp(34.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(18.0f, static_cast<Uint8>(std::clamp(54.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||
drawGlow(10.0f, static_cast<Uint8>(std::clamp(78.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||
|
||||
// 2) Hotspots near top/bottom (adds that “powered endpoints” vibe)
|
||||
SDL_Color hot = auraInner;
|
||||
hot.r = static_cast<Uint8>(std::min(255, static_cast<int>(hot.r) + 35));
|
||||
hot.g = static_cast<Uint8>(std::min(255, static_cast<int>(hot.g) + 35));
|
||||
hot.b = static_cast<Uint8>(std::min(255, static_cast<int>(hot.b) + 35));
|
||||
{
|
||||
const float hotW1 = 34.0f;
|
||||
const float hotW2 = 18.0f;
|
||||
SDL_FRect topHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y, m_rect.w + hotW1, hotspotH };
|
||||
SDL_FRect botHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y + m_rect.h - hotspotH, m_rect.w + hotW1, hotspotH };
|
||||
SDL_FRect topHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + hotspotH * 0.12f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||
SDL_FRect botHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + m_rect.h - hotspotH * 0.90f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||
|
||||
Uint8 ha1 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 85.0f : 55.0f) * flashBoost, 0.0f, 255.0f));
|
||||
Uint8 ha2 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 130.0f : 90.0f) * flashBoost, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, hot.r, hot.g, hot.b, ha1);
|
||||
SDL_RenderFillRect(renderer, &topHot1);
|
||||
SDL_RenderFillRect(renderer, &botHot1);
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ha2);
|
||||
SDL_RenderFillRect(renderer, &topHot2);
|
||||
SDL_RenderFillRect(renderer, &botHot2);
|
||||
}
|
||||
|
||||
// 3) Synced pulse wave (a travelling “breath” around the beam)
|
||||
if (m_state == SyncState::Synced) {
|
||||
float wave = std::fmod(m_pulseTime * 2.4f, 1.0f);
|
||||
float width = 10.0f + wave * 26.0f;
|
||||
Uint8 alpha = static_cast<Uint8>(std::clamp(150.0f * (1.0f - wave) * flashBoost, 0.0f, 255.0f));
|
||||
|
||||
SDL_FRect waveRect{
|
||||
centerX - (m_rect.w + width) * 0.5f,
|
||||
m_rect.y,
|
||||
m_rect.w + width,
|
||||
m_rect.h
|
||||
};
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, 140, 255, 220, alpha);
|
||||
SDL_RenderFillRect(renderer, &waveRect);
|
||||
}
|
||||
|
||||
// 4) Shimmer bands (stylish motion inside the conduit)
|
||||
{
|
||||
const int bands = 7;
|
||||
const float speed = (m_state == SyncState::Synced) ? 160.0f : 95.0f;
|
||||
const float bandW = m_rect.w + 12.0f;
|
||||
for (int i = 0; i < bands; ++i) {
|
||||
const float phase = (static_cast<float>(i) / static_cast<float>(bands));
|
||||
const float y = m_rect.y + std::fmod(m_time * speed + phase * h, h);
|
||||
const float fade = 0.35f + 0.65f * std::sinf((m_time * 2.1f) + phase * 6.28318f);
|
||||
const float bandH = 2.0f + (phase * 2.0f);
|
||||
Uint8 a = static_cast<Uint8>(std::clamp((26.0f + 36.0f * pulse01) * std::fabs(fade) * flashBoost, 0.0f, 255.0f));
|
||||
SDL_FRect fr{ centerX - bandW * 0.5f, y, bandW, bandH };
|
||||
SDL_SetRenderDrawColor(renderer, 200, 255, 255, a);
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Core beam (thin bright core + thicker body with horizontal gradient)
|
||||
Uint8 bodyA = color.a;
|
||||
if (m_state == SyncState::Synced) {
|
||||
bodyA = static_cast<Uint8>(std::clamp(175.0f + pulse01 * 75.0f, 0.0f, 255.0f));
|
||||
}
|
||||
// Keep the center more translucent; let glow carry intensity.
|
||||
bodyA = static_cast<Uint8>(std::clamp(bodyA * (0.72f + flashT * 0.35f), 0.0f, 255.0f));
|
||||
|
||||
// Render a smooth-looking body by stacking a few vertical strips.
|
||||
// This approximates a gradient (bright center -> soft edges) without shaders.
|
||||
{
|
||||
// Allow thinner beam while keeping gradient readable.
|
||||
const float bodyW = std::max(4.0f, m_rect.w);
|
||||
const float x0 = centerX - bodyW * 0.5f;
|
||||
|
||||
SDL_FRect left{ x0, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||
SDL_FRect mid{ x0 + bodyW * 0.34f, m_rect.y, bodyW * 0.32f, m_rect.h };
|
||||
SDL_FRect right{ x0 + bodyW * 0.66f, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, static_cast<Uint8>(std::clamp(bodyA * 0.60f, 0.0f, 255.0f)));
|
||||
SDL_RenderFillRect(renderer, &left);
|
||||
SDL_RenderFillRect(renderer, &right);
|
||||
|
||||
SDL_SetRenderDrawColor(renderer,
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.r) + 35)),
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.g) + 35)),
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.b) + 55)),
|
||||
static_cast<Uint8>(std::clamp(bodyA * 0.88f, 0.0f, 255.0f)));
|
||||
SDL_RenderFillRect(renderer, &mid);
|
||||
}
|
||||
|
||||
SDL_FRect coreRect{ centerX - 1.1f, m_rect.y, 2.2f, m_rect.h };
|
||||
Uint8 coreA = static_cast<Uint8>(std::clamp(210.0f + pulse01 * 70.0f + flashT * 95.0f, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, coreA);
|
||||
SDL_RenderFillRect(renderer, &coreRect);
|
||||
|
||||
// Switch back to normal alpha blend for particles so they stay readable.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
// 6) Energy particles (sparks/streaks traveling upward)
|
||||
for (const auto& p : m_particles) {
|
||||
Uint8 a = static_cast<Uint8>(std::clamp(p.alpha, 0.0f, 255.0f));
|
||||
|
||||
// Add a tiny sinusoidal sway so the stream feels alive.
|
||||
const float sway = std::sinf((p.y * 0.045f) + (m_time * 6.2f)) * 0.9f;
|
||||
SDL_FRect spark{ (p.x + sway) - (p.w * 0.5f), p.y, p.w, p.h };
|
||||
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, a);
|
||||
SDL_RenderFillRect(renderer, &spark);
|
||||
|
||||
// A little aura around each spark helps it read at speed.
|
||||
if (a > 40) {
|
||||
SDL_FRect sparkGlow{ spark.x - 1.0f, spark.y - 1.0f, spark.w + 2.0f, spark.h + 2.0f };
|
||||
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, static_cast<Uint8>(a * 0.35f));
|
||||
SDL_RenderFillRect(renderer, &sparkGlow);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Flash/glow overlay (adds “clear burst” punch)
|
||||
if (m_state == SyncState::ClearFlash) {
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
|
||||
const float extra = 74.0f;
|
||||
SDL_FRect glow{ centerX - (m_rect.w + extra) * 0.5f, m_rect.y, m_rect.w + extra, m_rect.h };
|
||||
Uint8 ga = static_cast<Uint8>(std::clamp(90.0f + 140.0f * flashT, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ga);
|
||||
SDL_RenderFillRect(renderer, &glow);
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
|
||||
// Restore whatever blend mode the caller had.
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
enum class SyncState {
|
||||
Idle,
|
||||
LeftReady,
|
||||
RightReady,
|
||||
Synced,
|
||||
ClearFlash
|
||||
};
|
||||
|
||||
class SyncLineRenderer {
|
||||
public:
|
||||
SyncLineRenderer();
|
||||
|
||||
void SetRect(const SDL_FRect& rect);
|
||||
void SetState(SyncState state);
|
||||
void TriggerClearFlash();
|
||||
|
||||
void Update(float deltaTime);
|
||||
void Render(SDL_Renderer* renderer);
|
||||
|
||||
private:
|
||||
struct SyncParticle {
|
||||
float x;
|
||||
float y;
|
||||
float vx;
|
||||
float vy;
|
||||
float w;
|
||||
float h;
|
||||
float alpha;
|
||||
SDL_Color color;
|
||||
};
|
||||
|
||||
SDL_FRect m_rect{};
|
||||
SyncState m_state;
|
||||
|
||||
float m_flashTimer;
|
||||
float m_time;
|
||||
|
||||
float m_pulseTime{0.0f};
|
||||
float m_spawnAcc{0.0f};
|
||||
std::vector<SyncParticle> m_particles;
|
||||
|
||||
static constexpr float FLASH_DURATION = 0.15f;
|
||||
static constexpr size_t MAX_PARTICLES = 240;
|
||||
|
||||
void SpawnParticle();
|
||||
void SpawnBurst(int count);
|
||||
|
||||
SDL_Color GetBaseColor() const;
|
||||
};
|
||||
@ -232,6 +232,6 @@ void UIRenderer::drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, floa
|
||||
|
||||
// Instructions
|
||||
font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
||||
{"ESC", "Back / cancel current popup"},
|
||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||
{"M", "Mute or unmute music"},
|
||||
{"S", "Toggle sound effects"}
|
||||
{"K", "Toggle sound effects"}
|
||||
}};
|
||||
|
||||
const std::array<ShortcutEntry, 2> menuShortcuts{{
|
||||
|
||||
182
src/network/supabase_client.cpp
Normal file
182
src/network/supabase_client.cpp
Normal file
@ -0,0 +1,182 @@
|
||||
#include "supabase_client.h"
|
||||
#include <curl/curl.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<std::string*>(userp);
|
||||
s->append(reinterpret_cast<char*>(contents), realSize);
|
||||
return realSize;
|
||||
}
|
||||
|
||||
struct CurlInit {
|
||||
CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); }
|
||||
~CurlInit() { curl_global_cleanup(); }
|
||||
};
|
||||
static CurlInit g_curl_init;
|
||||
}
|
||||
|
||||
namespace supabase {
|
||||
|
||||
static bool g_verbose = false;
|
||||
|
||||
void SetVerbose(bool enabled) {
|
||||
g_verbose = enabled;
|
||||
}
|
||||
|
||||
|
||||
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<int>(std::lround(entry.timeSec));
|
||||
j["name"] = entry.name;
|
||||
j["game_type"] = entry.gameType;
|
||||
j["timestamp"] = static_cast<int>(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);
|
||||
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] POST " << url << "\n";
|
||||
std::cerr << "[Supabase] Body: " << body << "\n";
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
if (res != CURLE_OK) {
|
||||
if (g_verbose) std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n";
|
||||
} else {
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
if (g_verbose) {
|
||||
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<ScoreEntry> FetchHighscores(const std::string &gameType, int limit) {
|
||||
std::vector<ScoreEntry> out;
|
||||
try {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return out;
|
||||
|
||||
std::string path = "highscores";
|
||||
// Clamp limit to max 10 to keep payloads small
|
||||
int l = std::clamp(limit, 1, 10);
|
||||
std::string query;
|
||||
if (!gameType.empty()) {
|
||||
if (gameType == "challenge") {
|
||||
query = "?game_type=eq." + gameType + "&order=level.desc,time_sec.asc&limit=" + std::to_string(l);
|
||||
} else {
|
||||
query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(l);
|
||||
}
|
||||
} else {
|
||||
query = "?order=score.desc&limit=" + std::to_string(l);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (g_verbose) 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);
|
||||
if (g_verbose) {
|
||||
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<int>();
|
||||
if (v.contains("lines")) e.lines = v["lines"].get<int>();
|
||||
if (v.contains("level")) e.level = v["level"].get<int>();
|
||||
if (v.contains("time_sec")) {
|
||||
try { e.timeSec = v["time_sec"].get<double>(); } catch(...) { e.timeSec = v["time_sec"].get<int>(); }
|
||||
} else if (v.contains("timestamp")) {
|
||||
e.timeSec = v["timestamp"].get<int>();
|
||||
}
|
||||
if (v.contains("name")) e.name = v["name"].get<std::string>();
|
||||
if (v.contains("game_type")) e.gameType = v["game_type"].get<std::string>();
|
||||
out.push_back(e);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
if (g_verbose) std::cerr << "[Supabase] GET parse error" << std::endl;
|
||||
}
|
||||
} else {
|
||||
if (g_verbose) 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
|
||||
17
src/network/supabase_client.h
Normal file
17
src/network/supabase_client.h
Normal file
@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#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<ScoreEntry> FetchHighscores(const std::string &gameType, int limit);
|
||||
|
||||
// Enable or disable verbose logging to stderr. Disabled by default.
|
||||
void SetVerbose(bool enabled);
|
||||
|
||||
} // namespace supabase
|
||||
@ -1,20 +1,18 @@
|
||||
// Scores.cpp - Implementation of ScoreManager with Firebase Sync
|
||||
// Scores.cpp - Implementation of ScoreManager
|
||||
#include "Scores.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cpr/cpr.h>
|
||||
#include "../network/supabase_client.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
|
||||
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,48 +25,19 @@ 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"];
|
||||
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
|
||||
// Request only 10 records from Supabase to keep payload small
|
||||
auto fetched = supabase::FetchHighscores("", 10);
|
||||
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
|
||||
@ -86,11 +55,32 @@ void ScoreManager::load() {
|
||||
ScoreEntry e;
|
||||
iss >> e.score >> e.lines >> e.level >> e.timeSec;
|
||||
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::getline(iss, remaining);
|
||||
if (!remaining.empty() && remaining[0] == ' ') {
|
||||
e.name = remaining.substr(1); // Remove leading space
|
||||
if (!remaining.empty() && remaining[0] == ' ') remaining = remaining.substr(1);
|
||||
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";
|
||||
}
|
||||
scores.push_back(e);
|
||||
}
|
||||
@ -108,42 +98,28 @@ void ScoreManager::load() {
|
||||
void ScoreManager::save() const {
|
||||
std::ofstream f(filePath(), std::ios::trunc);
|
||||
for (auto &e : scores) {
|
||||
f << e.score << ' ' << e.lines << ' ' << e.level << ' ' << e.timeSec << ' ' << e.name << '\n';
|
||||
// Save gameType as trailing token so future loads can preserve it
|
||||
f << e.score << ' ' << e.lines << ' ' << e.level << ' ' << e.timeSec << ' ' << e.name << ' ' << e.gameType << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name) {
|
||||
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;
|
||||
// preserve the game type locally so menu filtering works immediately
|
||||
newEntry.gameType = gameType;
|
||||
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
|
||||
|
||||
// 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 {
|
||||
@ -151,19 +127,28 @@ bool ScoreManager::isHighScore(int score) const {
|
||||
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() {
|
||||
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"}
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
struct ScoreEntry { int score{}; int lines{}; int level{}; double timeSec{}; std::string name{"PLAYER"}; };
|
||||
struct ScoreEntry { int score{}; int lines{}; int level{}; double timeSec{}; std::string name{"PLAYER"}; std::string gameType{"classic"}; };
|
||||
|
||||
class ScoreManager {
|
||||
public:
|
||||
explicit ScoreManager(size_t maxScores = 12);
|
||||
void load();
|
||||
void save() const;
|
||||
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER");
|
||||
// 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`.
|
||||
// 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;
|
||||
const std::vector<ScoreEntry>& all() const { return scores; }
|
||||
private:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "MenuState.h"
|
||||
#include "persistence/Scores.h"
|
||||
#include "../network/supabase_client.h"
|
||||
#include "graphics/Font.h"
|
||||
#include "../graphics/ui/HelpOverlay.h"
|
||||
#include "../core/GlobalState.h"
|
||||
@ -169,6 +170,24 @@ void MenuState::onEnter() {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = 1;
|
||||
}
|
||||
// Refresh highscores for classic/cooperate/challenge asynchronously
|
||||
try {
|
||||
std::thread([this]() {
|
||||
try {
|
||||
auto c_classic = supabase::FetchHighscores("classic", 10);
|
||||
auto c_coop = supabase::FetchHighscores("cooperate", 10);
|
||||
auto c_challenge = supabase::FetchHighscores("challenge", 10);
|
||||
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) {
|
||||
@ -442,7 +461,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
case SDL_SCANCODE_LEFT:
|
||||
case SDL_SCANCODE_UP:
|
||||
{
|
||||
const int total = 7;
|
||||
const int total = MENU_BTN_COUNT;
|
||||
selectedButton = (selectedButton + total - 1) % total;
|
||||
// brief bright flash on navigation
|
||||
buttonFlash = 1.0;
|
||||
@ -451,7 +470,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
case SDL_SCANCODE_DOWN:
|
||||
{
|
||||
const int total = 7;
|
||||
const int total = MENU_BTN_COUNT;
|
||||
selectedButton = (selectedButton + 1) % total;
|
||||
// brief bright flash on navigation
|
||||
buttonFlash = 1.0;
|
||||
@ -470,6 +489,17 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
triggerPlay();
|
||||
break;
|
||||
case 1:
|
||||
// Cooperative play
|
||||
if (ctx.game) {
|
||||
ctx.game->setMode(GameMode::Cooperate);
|
||||
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||
}
|
||||
if (ctx.coopGame) {
|
||||
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||
}
|
||||
triggerPlay();
|
||||
break;
|
||||
case 2:
|
||||
// Start challenge run at level 1
|
||||
if (ctx.game) {
|
||||
ctx.game->setMode(GameMode::Challenge);
|
||||
@ -480,7 +510,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
}
|
||||
triggerPlay();
|
||||
break;
|
||||
case 2:
|
||||
case 3:
|
||||
// Toggle inline level selector HUD (show/hide)
|
||||
if (!levelPanelVisible && !levelPanelAnimating) {
|
||||
levelPanelAnimating = true;
|
||||
@ -492,7 +522,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
levelDirection = -1; // hide
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
// Toggle the options panel with an animated slide-in/out.
|
||||
if (!optionsVisible && !optionsAnimating) {
|
||||
optionsAnimating = true;
|
||||
@ -502,7 +532,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
optionsDirection = -1; // hide
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
case 5:
|
||||
// Toggle the inline HELP HUD (show/hide)
|
||||
if (!helpPanelVisible && !helpPanelAnimating) {
|
||||
helpPanelAnimating = true;
|
||||
@ -513,7 +543,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
helpDirection = -1; // hide
|
||||
}
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
// Toggle the inline ABOUT HUD (show/hide)
|
||||
if (!aboutPanelVisible && !aboutPanelAnimating) {
|
||||
aboutPanelAnimating = true;
|
||||
@ -523,7 +553,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
||||
aboutDirection = -1;
|
||||
}
|
||||
break;
|
||||
case 6:
|
||||
case 7:
|
||||
// Show the inline exit HUD
|
||||
if (!exitPanelVisible && !exitPanelAnimating) {
|
||||
exitPanelAnimating = true;
|
||||
@ -771,7 +801,9 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
||||
// Move the whole block slightly up to better match the main screen overlay framing.
|
||||
float menuYOffset = LOGICAL_H * 0.03f; // same offset used for buttons
|
||||
float scoresYOffset = -LOGICAL_H * 0.05f;
|
||||
float topPlayersY = LOGICAL_H * 0.20f + contentOffsetY - panelDelta + menuYOffset + scoresYOffset;
|
||||
// Move logo and highscores upward by ~10% of logical height for better vertical balance
|
||||
float upwardShift = LOGICAL_H * 0.08f;
|
||||
float topPlayersY = LOGICAL_H * 0.20f + contentOffsetY - panelDelta + menuYOffset + scoresYOffset - upwardShift;
|
||||
float scoresStartY = topPlayersY;
|
||||
if (useFont) {
|
||||
// Preferred logo texture (full) if present, otherwise the small logo
|
||||
@ -802,11 +834,22 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
||||
}
|
||||
static const std::vector<ScoreEntry> EMPTY_SCORES;
|
||||
const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES;
|
||||
size_t maxDisplay = std::min(hs.size(), size_t(10)); // display only top 10
|
||||
// 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<ScoreEntry> filtered;
|
||||
filtered.reserve(hs.size());
|
||||
for (const auto &e : hs) {
|
||||
if (e.gameType == wantedType) filtered.push_back(e);
|
||||
}
|
||||
size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10
|
||||
|
||||
// Draw highscores as an inline HUD-like panel (no opaque box), matching Options/Level/Exit style
|
||||
if (useFont) {
|
||||
const float panelW = std::min(780.0f, LOGICAL_W * 0.85f);
|
||||
const float panelW = (wantedType == "cooperate") ? std::min(920.0f, LOGICAL_W * 0.92f) : std::min(780.0f, LOGICAL_W * 0.85f);
|
||||
const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows
|
||||
// Shift the entire highscores panel slightly left (~1.5% of logical width)
|
||||
float panelShift = LOGICAL_W * 0.015f;
|
||||
@ -821,9 +864,9 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
||||
// Tighter column spacing: compress multipliers around center
|
||||
float rankX = centerX - colWidth * 0.34f;
|
||||
// Move PLAYER column a bit further left while leaving others unchanged
|
||||
float nameX = centerX - colWidth * 0.25f;
|
||||
// Move SCORE column slightly left for tighter layout
|
||||
float scoreX = centerX - colWidth * 0.06f;
|
||||
float nameX = (wantedType == "cooperate") ? centerX - colWidth * 0.30f : centerX - colWidth * 0.25f;
|
||||
// Move SCORE column slightly left for tighter layout (adjusted for coop)
|
||||
float scoreX = (wantedType == "cooperate") ? centerX - colWidth * 0.02f : centerX - colWidth * 0.06f;
|
||||
float linesX = centerX + colWidth * 0.14f;
|
||||
float levelX = centerX + colWidth * 0.26f;
|
||||
float timeX = centerX + colWidth * 0.38f;
|
||||
@ -835,7 +878,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
||||
// Use same color as Options heading (use full alpha for maximum brightness)
|
||||
SDL_Color headerColor = SDL_Color{120,220,255,255};
|
||||
useFont->draw(renderer, rankX, headerY, "#", headerScale, headerColor);
|
||||
useFont->draw(renderer, nameX, headerY, "PLAYER", headerScale, headerColor);
|
||||
useFont->draw(renderer, nameX, headerY, (wantedType == "cooperate") ? "PLAYERS" : "PLAYER", headerScale, headerColor);
|
||||
useFont->draw(renderer, scoreX, headerY, "SCORE", headerScale, headerColor);
|
||||
useFont->draw(renderer, linesX, headerY, "LINES", headerScale, headerColor);
|
||||
useFont->draw(renderer, levelX, headerY, "LVL", headerScale, headerColor);
|
||||
@ -888,18 +931,18 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
||||
char rankStr[8]; std::snprintf(rankStr, sizeof(rankStr), "%zu.", i + 1);
|
||||
useFont->draw(renderer, rankX, y + wave + entryOffset, rankStr, curRowScale, rowColor);
|
||||
|
||||
useFont->draw(renderer, nameXAdj, y + wave + entryOffset, hs[i].name, curRowScale, rowColor);
|
||||
useFont->draw(renderer, nameXAdj, y + wave + entryOffset, filtered[i].name, curRowScale, rowColor);
|
||||
|
||||
char scoreStr[16]; std::snprintf(scoreStr, sizeof(scoreStr), "%d", hs[i].score);
|
||||
char scoreStr[16]; std::snprintf(scoreStr, sizeof(scoreStr), "%d", filtered[i].score);
|
||||
useFont->draw(renderer, scoreX, y + wave + entryOffset, scoreStr, curRowScale, rowColor);
|
||||
|
||||
char linesStr[8]; std::snprintf(linesStr, sizeof(linesStr), "%d", hs[i].lines);
|
||||
char linesStr[8]; std::snprintf(linesStr, sizeof(linesStr), "%d", filtered[i].lines);
|
||||
useFont->draw(renderer, linesX, y + wave + entryOffset, linesStr, curRowScale, rowColor);
|
||||
|
||||
char levelStr[8]; std::snprintf(levelStr, sizeof(levelStr), "%d", hs[i].level);
|
||||
char levelStr[8]; std::snprintf(levelStr, sizeof(levelStr), "%d", filtered[i].level);
|
||||
useFont->draw(renderer, levelX, y + wave + entryOffset, levelStr, curRowScale, rowColor);
|
||||
|
||||
char timeStr[16]; int mins = int(hs[i].timeSec) / 60; int secs = int(hs[i].timeSec) % 60;
|
||||
char timeStr[16]; int mins = int(filtered[i].timeSec) / 60; int secs = int(filtered[i].timeSec) % 60;
|
||||
std::snprintf(timeStr, sizeof(timeStr), "%d:%02d", mins, secs);
|
||||
useFont->draw(renderer, timeX, y + wave + entryOffset, timeStr, curRowScale, rowColor);
|
||||
}
|
||||
@ -1237,7 +1280,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
||||
{"ESC", "Back / cancel current popup"},
|
||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||
{"M", "Mute or unmute music"},
|
||||
{"S", "Toggle sound effects"}
|
||||
{"K", "Toggle sound effects"}
|
||||
};
|
||||
const ShortcutEntry menuShortcuts[] = {
|
||||
{"ARROW KEYS", "Navigate menu buttons"},
|
||||
|
||||
@ -21,7 +21,7 @@ public:
|
||||
void showAboutPanel(bool show);
|
||||
|
||||
private:
|
||||
int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = HELP, 4 = ABOUT, 5 = EXIT
|
||||
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT
|
||||
|
||||
// Button icons (optional - will use text if nullptr)
|
||||
SDL_Texture* playIcon = nullptr;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#include "PlayingState.h"
|
||||
#include "../core/state/StateManager.h"
|
||||
#include "../gameplay/core/Game.h"
|
||||
#include "../gameplay/coop/CoopGame.h"
|
||||
#include "../gameplay/effects/LineEffect.h"
|
||||
#include "../persistence/Scores.h"
|
||||
#include "../audio/Audio.h"
|
||||
@ -18,12 +19,15 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
|
||||
|
||||
void PlayingState::onEnter() {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
|
||||
// Initialize the game based on mode: endless uses chosen start level, challenge keeps its run state
|
||||
// Initialize the game based on mode: endless/cooperate use chosen start level, challenge keeps its run state
|
||||
if (ctx.game) {
|
||||
if (ctx.game->getMode() == GameMode::Endless) {
|
||||
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
|
||||
if (ctx.startLevelSelection) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
||||
ctx.coopGame->reset(*ctx.startLevelSelection);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
||||
@ -45,124 +49,176 @@ void PlayingState::onExit() {
|
||||
}
|
||||
|
||||
void PlayingState::handleEvent(const SDL_Event& e) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
// If a transport animation is active, ignore gameplay input entirely.
|
||||
if (GameRenderer::isTransportActive()) {
|
||||
return;
|
||||
}
|
||||
// We keep short-circuited input here; main still owns mouse UI
|
||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
auto setExitSelection = [&](int value) {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = value;
|
||||
}
|
||||
};
|
||||
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||
|
||||
auto getExitSelection = [&]() -> int {
|
||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||
};
|
||||
auto setExitSelection = [&](int idx) {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = idx;
|
||||
}
|
||||
};
|
||||
auto getExitSelection = [&]() -> int {
|
||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||
};
|
||||
|
||||
// Pause toggle (P)
|
||||
if (e.key.scancode == SDL_SCANCODE_P) {
|
||||
bool paused = ctx.game->isPaused();
|
||||
ctx.game->setPaused(!paused);
|
||||
if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If exit-confirm popup is visible, handle shortcuts here
|
||||
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
||||
// Navigate between YES (0) and NO (1) buttons
|
||||
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
||||
setExitSelection(0);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// If exit-confirm popup is visible, handle shortcuts here
|
||||
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
||||
// Navigate between YES (0) and NO (1) buttons
|
||||
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
||||
setExitSelection(0);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate selected button with Enter or Space
|
||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
const bool confirmExit = (getExitSelection() == 0);
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
if (confirmExit) {
|
||||
// YES - Reset game and return to menu
|
||||
if (ctx.startLevelSelection) {
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
} else {
|
||||
ctx.game->reset(0);
|
||||
}
|
||||
ctx.game->setPaused(false);
|
||||
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
||||
// Activate selected button with Enter or Space
|
||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
const bool confirmExit = (getExitSelection() == 0);
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
if (confirmExit) {
|
||||
// YES - Reset game and return to menu
|
||||
if (ctx.startLevelSelection) {
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
} else {
|
||||
// NO - Just close popup and resume
|
||||
ctx.game->setPaused(false);
|
||||
ctx.game->reset(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cancel with Esc (same as NO)
|
||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
ctx.game->setPaused(false);
|
||||
setExitSelection(1);
|
||||
return;
|
||||
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
||||
} else {
|
||||
// NO - Just close popup and resume
|
||||
ctx.game->setPaused(false);
|
||||
}
|
||||
// While modal is open, suppress other gameplay keys
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC key - open confirmation popup
|
||||
// Cancel with Esc (same as NO)
|
||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
if (ctx.game) ctx.game->setPaused(true);
|
||||
*ctx.showExitConfirmPopup = true;
|
||||
setExitSelection(1); // Default to NO for safety
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: skip to next challenge level (B)
|
||||
if (e.key.scancode == SDL_SCANCODE_B && ctx.game && ctx.game->getMode() == GameMode::Challenge) {
|
||||
ctx.game->beginNextChallengeLevel();
|
||||
// Cancel any countdown so play resumes immediately on the new level
|
||||
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||||
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
ctx.game->setPaused(false);
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
// While modal is open, suppress other gameplay keys
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC key - open confirmation popup
|
||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
ctx.game->setPaused(true);
|
||||
*ctx.showExitConfirmPopup = true;
|
||||
setExitSelection(1); // Default to NO for safety
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: skip to next challenge level (B)
|
||||
if (e.key.scancode == SDL_SCANCODE_B && ctx.game->getMode() == GameMode::Challenge) {
|
||||
ctx.game->beginNextChallengeLevel();
|
||||
// Cancel any countdown so play resumes immediately on the new level
|
||||
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||||
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||||
ctx.game->setPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause toggle (P) - matches classic behavior; disabled during countdown
|
||||
if (e.key.scancode == SDL_SCANCODE_P) {
|
||||
const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) ||
|
||||
(ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed);
|
||||
if (!countdown) {
|
||||
ctx.game->setPaused(!ctx.game->isPaused());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tetris controls (only when not paused)
|
||||
if (ctx.game->isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (coopActive && ctx.coopGame) {
|
||||
// Player 1 (left): A/D move via DAS in ApplicationManager; here handle rotations/hold/hard-drop
|
||||
if (e.key.scancode == SDL_SCANCODE_W) {
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_Q) {
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1);
|
||||
return;
|
||||
}
|
||||
// Hard drop (left): keep LSHIFT, also allow E for convenience.
|
||||
if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_LCTRL) {
|
||||
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tetris controls (only when not paused)
|
||||
if (!ctx.game->isPaused()) {
|
||||
// Hold / swap current piece (H)
|
||||
if (e.key.scancode == SDL_SCANCODE_H) {
|
||||
ctx.game->holdCurrent();
|
||||
return;
|
||||
}
|
||||
// Player 2 (right): arrow keys move via DAS; rotations/hold/hard-drop here
|
||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RALT) {
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, -1);
|
||||
return;
|
||||
}
|
||||
// Hard drop (right): SPACE is the primary key for arrow controls; keep RSHIFT as an alternate.
|
||||
if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
|
||||
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Right);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Single-player classic controls
|
||||
// Hold / swap current piece (H)
|
||||
if (e.key.scancode == SDL_SCANCODE_H) {
|
||||
ctx.game->holdCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rotation (still event-based for precise timing)
|
||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||
// Use user setting to determine whether UP rotates clockwise
|
||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.game->rotate(upIsCW ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_X) {
|
||||
// Toggle the mapping so UP will rotate in the opposite direction
|
||||
bool current = Settings::instance().isUpRotateClockwise();
|
||||
Settings::instance().setUpRotateClockwise(!current);
|
||||
Settings::instance().save();
|
||||
// Play a subtle feedback sound if available
|
||||
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
||||
return;
|
||||
}
|
||||
// Rotation (still event-based for precise timing)
|
||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||
// Use user setting to determine whether UP rotates clockwise
|
||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.game->rotate(upIsCW ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_X) {
|
||||
// Toggle the mapping so UP will rotate in the opposite direction
|
||||
bool current = Settings::instance().isUpRotateClockwise();
|
||||
Settings::instance().setUpRotateClockwise(!current);
|
||||
Settings::instance().save();
|
||||
// Play a subtle feedback sound if available
|
||||
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard drop (space)
|
||||
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.game->hardDrop();
|
||||
return;
|
||||
}
|
||||
// Hard drop (space)
|
||||
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.game->hardDrop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,6 +229,20 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
||||
void PlayingState::update(double frameMs) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||
|
||||
if (coopActive) {
|
||||
// Visual effects only; gravity and movement handled from ApplicationManager for coop
|
||||
ctx.coopGame->updateVisualEffects(frameMs);
|
||||
// Update line clear effect for coop mode as well (renderer starts the effect)
|
||||
if (ctx.lineEffect && ctx.lineEffect->isActive()) {
|
||||
if (ctx.lineEffect->update(frameMs / 1000.0f)) {
|
||||
ctx.coopGame->clearCompletedLines();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.game->updateVisualEffects(frameMs);
|
||||
// If a transport animation is active, pause gameplay updates and ignore inputs
|
||||
if (GameRenderer::isTransportActive()) {
|
||||
@ -204,6 +274,8 @@ void PlayingState::update(double frameMs) {
|
||||
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||
|
||||
// Get current window size
|
||||
int winW = 0, winH = 0;
|
||||
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
||||
@ -244,26 +316,45 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
// Render game content (no overlays)
|
||||
// If a transport effect was requested due to a recent spawn, start it here so
|
||||
// the renderer has the correct layout and renderer context to compute coords.
|
||||
if (s_pendingTransport) {
|
||||
if (!coopActive && s_pendingTransport) {
|
||||
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||
s_pendingTransport = false;
|
||||
}
|
||||
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f, // LOGICAL_W
|
||||
1000.0f, // LOGICAL_H
|
||||
logicalScale,
|
||||
if (coopActive && ctx.coopGame) {
|
||||
GameRenderer::renderCoopPlayingState(
|
||||
renderer,
|
||||
ctx.coopGame,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
paused,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH
|
||||
);
|
||||
} else {
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f, // LOGICAL_W
|
||||
1000.0f, // LOGICAL_H
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH,
|
||||
challengeClearFx,
|
||||
@ -272,7 +363,8 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
challengeClearDuration,
|
||||
countdown ? nullptr : ctx.challengeStoryText,
|
||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// Reset to screen
|
||||
SDL_SetRenderTarget(renderer, nullptr);
|
||||
@ -341,33 +433,54 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
|
||||
} else {
|
||||
// Render normally directly to screen
|
||||
if (s_pendingTransport) {
|
||||
if (!coopActive && s_pendingTransport) {
|
||||
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||
s_pendingTransport = false;
|
||||
}
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH,
|
||||
challengeClearFx,
|
||||
challengeClearOrder,
|
||||
challengeClearElapsed,
|
||||
challengeClearDuration,
|
||||
countdown ? nullptr : ctx.challengeStoryText,
|
||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||
);
|
||||
|
||||
if (coopActive && ctx.coopGame) {
|
||||
GameRenderer::renderCoopPlayingState(
|
||||
renderer,
|
||||
ctx.coopGame,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
paused,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH
|
||||
);
|
||||
} else {
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH,
|
||||
challengeClearFx,
|
||||
challengeClearOrder,
|
||||
challengeClearElapsed,
|
||||
challengeClearDuration,
|
||||
countdown ? nullptr : ctx.challengeStoryText,
|
||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
// Forward declarations for frequently used types
|
||||
class Game;
|
||||
class CoopGame;
|
||||
class ScoreManager;
|
||||
class Starfield;
|
||||
class Starfield3D;
|
||||
@ -24,6 +25,7 @@ class StateManager;
|
||||
struct StateContext {
|
||||
// Core subsystems (may be null if not available)
|
||||
Game* game = nullptr;
|
||||
CoopGame* coopGame = nullptr;
|
||||
ScoreManager* scores = nullptr;
|
||||
Starfield* starfield = nullptr;
|
||||
Starfield3D* starfield3D = nullptr;
|
||||
|
||||
@ -22,12 +22,13 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
||||
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
||||
|
||||
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
||||
menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false };
|
||||
menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true };
|
||||
menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true };
|
||||
menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true };
|
||||
menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true };
|
||||
menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", true };
|
||||
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
|
||||
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
|
||||
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
|
||||
menu.buttons[4] = Button{ BottomMenuItem::Options, rects[4], "OPTIONS", true };
|
||||
menu.buttons[5] = Button{ BottomMenuItem::Help, rects[5], "HELP", true };
|
||||
menu.buttons[6] = Button{ BottomMenuItem::About, rects[6], "ABOUT", true };
|
||||
menu.buttons[7] = Button{ BottomMenuItem::Exit, rects[7], "EXIT", true };
|
||||
|
||||
return menu;
|
||||
}
|
||||
@ -62,10 +63,15 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
||||
|
||||
if (!b.textOnly) {
|
||||
const bool isPlay = (i == 0);
|
||||
const bool isChallenge = (i == 1);
|
||||
const bool isCoop = (i == 1);
|
||||
const bool isChallenge = (i == 2);
|
||||
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
||||
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
||||
if (isChallenge) {
|
||||
if (isCoop) {
|
||||
// Cooperative mode gets a cyan/magenta accent to separate from Endless/Challenge
|
||||
bgCol = SDL_Color{ 22, 30, 40, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||
bdCol = SDL_Color{ 160, 210, 255, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||
} else if (isChallenge) {
|
||||
// Give Challenge a teal accent to distinguish from Play
|
||||
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||
@ -82,14 +88,14 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
||||
}
|
||||
}
|
||||
|
||||
// '+' separators between the bottom HUD buttons (indices 2..last)
|
||||
// '+' separators between the bottom HUD buttons (indices 3..last)
|
||||
{
|
||||
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
||||
|
||||
const int firstSmall = 2;
|
||||
const int firstSmall = 3;
|
||||
const int lastSmall = MENU_BTN_COUNT - 1;
|
||||
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
||||
for (int i = firstSmall; i < lastSmall; ++i) {
|
||||
|
||||
@ -15,12 +15,13 @@ namespace ui {
|
||||
|
||||
enum class BottomMenuItem : int {
|
||||
Play = 0,
|
||||
Challenge = 1,
|
||||
Level = 2,
|
||||
Options = 3,
|
||||
Help = 4,
|
||||
About = 5,
|
||||
Exit = 6,
|
||||
Cooperate = 1,
|
||||
Challenge = 2,
|
||||
Level = 3,
|
||||
Options = 4,
|
||||
Help = 5,
|
||||
About = 6,
|
||||
Exit = 7,
|
||||
};
|
||||
|
||||
struct Button {
|
||||
@ -37,8 +38,8 @@ struct BottomMenu {
|
||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel);
|
||||
|
||||
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
|
||||
// hoveredIndex: -1..5
|
||||
// selectedIndex: 0..5 (keyboard selection)
|
||||
// hoveredIndex: -1..7
|
||||
// selectedIndex: 0..7 (keyboard selection)
|
||||
// alphaMul: 0..1 (overall group alpha)
|
||||
void renderBottomMenu(SDL_Renderer* renderer,
|
||||
FontAtlas* font,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
#include "ui/MenuLayout.h"
|
||||
#include "ui/UIConstants.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace ui {
|
||||
|
||||
@ -12,7 +13,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
||||
|
||||
// Cockpit HUD layout (matches main_screen art):
|
||||
// - Top row: PLAY and CHALLENGE (big buttons)
|
||||
// - Top row: PLAY / COOPERATE / CHALLENGE (big buttons)
|
||||
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
||||
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
||||
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
||||
@ -26,9 +27,10 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float smallSpacing = 26.0f;
|
||||
|
||||
// Scale down for narrow windows so nothing goes offscreen.
|
||||
const int smallCount = MENU_BTN_COUNT - 2;
|
||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||
float topRowTotal = playW * 2.0f + bigGap;
|
||||
const int bigCount = 3;
|
||||
const int smallCount = MENU_BTN_COUNT - bigCount;
|
||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(std::max(smallCount - 1, 0));
|
||||
float topRowTotal = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||
if (smallTotal > availableW || topRowTotal > availableW) {
|
||||
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
||||
smallW *= s;
|
||||
@ -48,11 +50,13 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
||||
|
||||
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
||||
// Top row big buttons
|
||||
float playLeft = centerX - (playW + bigGap * 0.5f);
|
||||
float challengeLeft = centerX + bigGap * 0.5f;
|
||||
rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH };
|
||||
rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH };
|
||||
// Top row big buttons (PLAY / COOPERATE / CHALLENGE)
|
||||
float bigRowW = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||
float leftBig = centerX - bigRowW * 0.5f;
|
||||
for (int i = 0; i < bigCount; ++i) {
|
||||
float x = leftBig + i * (playW + bigGap);
|
||||
rects[i] = SDL_FRect{ x, playCY - playH * 0.5f, playW, playH };
|
||||
}
|
||||
|
||||
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||
float left = centerX - rowW * 0.5f;
|
||||
@ -63,7 +67,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
|
||||
for (int i = 0; i < smallCount; ++i) {
|
||||
float x = left + i * (smallW + smallSpacing);
|
||||
rects[i + 2] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||
rects[i + bigCount] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ struct MenuLayoutParams {
|
||||
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
|
||||
|
||||
// Hit test a point given in logical content-local coordinates against menu buttons
|
||||
// Returns index 0..4 or -1 if none
|
||||
// Returns index 0..(MENU_BTN_COUNT-1) or -1 if none
|
||||
int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
|
||||
|
||||
// Return settings button rect (logical coords)
|
||||
|
||||
@ -83,6 +83,6 @@ void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicE
|
||||
bool sfxOn = true;
|
||||
font.draw(renderer, popupX + 140, popupY + 100, sfxOn ? "ON" : "OFF", 1.5f, sfxOn ? SDL_Color{0,255,0,255} : SDL_Color{255,0,0,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, SDL_Color{200,200,220,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, SDL_Color{200,200,220,255});
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
static constexpr int MENU_BTN_COUNT = 7;
|
||||
static constexpr int MENU_BTN_COUNT = 8;
|
||||
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
||||
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
||||
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
||||
|
||||
Reference in New Issue
Block a user