diff --git a/CMakeLists.txt b/CMakeLists.txt index e8edcb3..2882987 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/settings.ini b/settings.ini index 99029f7..86008f0 100644 --- a/settings.ini +++ b/settings.ini @@ -5,7 +5,7 @@ Fullscreen=1 [Audio] -Music=1 +Music=0 Sound=1 [Gameplay] diff --git a/src/app/Fireworks.cpp b/src/app/Fireworks.cpp index 8b9b01f..4e25ae1 100644 --- a/src/app/Fireworks.cpp +++ b/src/app/Fireworks.cpp @@ -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 diff --git a/src/app/Fireworks.h b/src/app/Fireworks.h index fc4ca62..c28b419 100644 --- a/src/app/Fireworks.h +++ b/src/app/Fireworks.h @@ -6,4 +6,5 @@ namespace AppFireworks { void update(double frameMs); double getLogoAnimCounter(); int getHoveredButton(); + void spawn(float x, float y); } diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index db0b29e..594f979 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -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 loadingStep{0}; std::unique_ptr game; + std::unique_ptr coopGame; std::vector singleSounds; std::vector doubleSounds; std::vector 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(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(nameW)) * 0.5f + contentOffsetX; + float textY = inputY + (inputH - static_cast(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(cursorW)) * 0.5f + contentOffsetX : textX + static_cast(nameW); + float cursorY = inputY + (inputH - static_cast(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(nameW)) * 0.5f + contentOffsetX; + float textY = inputY + (inputH - static_cast(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(cursorW)) * 0.5f + contentOffsetX : textX + static_cast(nameW); + float cursorY = inputY + (inputH - static_cast(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(nameW)) * 0.5f + contentOffsetX; - float textY = inputY + (inputH - static_cast(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(cursorW)) * 0.5f + contentOffsetX - : textX + static_cast(nameW); - float cursorY = inputY + (inputH - static_cast(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()); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 89ea991..f919208 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -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 #include @@ -561,6 +562,7 @@ bool ApplicationManager::initializeGame() { m_lineEffect->init(m_renderManager->getSDLRenderer()); } m_game = std::make_unique(m_startLevelSelection); + m_coopGame = std::make_unique(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}); @@ -1237,74 +1264,160 @@ void ApplicationManager::setupStateHandlers() { m_stateManager->registerUpdateHandler(AppState::Playing, [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()); - - // Handle DAS/ARR movement timing (from original main.cpp) - int moveDir = 0; - if (left && !right) - moveDir = -1; - else if (right && !left) - moveDir = +1; - - 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) { - m_stateContext.scores->submit( - m_stateContext.game->score(), - m_stateContext.game->lines(), - m_stateContext.game->level(), - m_stateContext.game->elapsed() - ); + 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; + } + + 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]; + + // 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 { + 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()); + + // Handle DAS/ARR movement timing (from original main.cpp) + int moveDir = 0; + if (left && !right) + moveDir = -1; + else if (right && !left) + moveDir = +1; + + 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 diff --git a/src/core/application/ApplicationManager.h b/src/core/application/ApplicationManager.h index 220b2a5..ea6e43c 100644 --- a/src/core/application/ApplicationManager.h +++ b/src/core/application/ApplicationManager.h @@ -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 m_scoreManager; // Gameplay pieces std::unique_ptr m_game; + std::unique_ptr m_coopGame; std::unique_ptr 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; diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp new file mode 100644 index 0000000..aad5d23 --- /dev/null +++ b/src/gameplay/coop/CoopGame.cpp @@ -0,0 +1,498 @@ +#include "CoopGame.h" + +#include +#include + +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 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(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(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(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(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; + } +} diff --git a/src/gameplay/coop/CoopGame.h b/src/gameplay/coop/CoopGame.h new file mode 100644 index 0000000..3377a50 --- /dev/null +++ b/src/gameplay/coop/CoopGame.h @@ -0,0 +1,161 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#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 bag{}; // 7-bag queue + std::mt19937 rng{ std::random_device{}() }; + }; + + explicit CoopGame(int startLevel = 0); + using SoundCallback = std::function; + using LevelUpCallback = std::function; + 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& 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(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& getCompletedLines() const { return completedLines; } + bool hasCompletedLines() const { return !completedLines.empty(); } + void clearCompletedLines(); + const std::array& rowHalfStates() const { return rowStates; } + + // Simple visual-effect compatibility (stubbed for now) + bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; } + double hardDropShakeStrength() const; + const std::vector& 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 board{}; + std::array 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 completedLines; + + // Impact FX + double hardDropShakeTimerMs{0.0}; + static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0; + std::vector 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; } +}; diff --git a/src/gameplay/core/Game.h b/src/gameplay/core/Game.h index 4899f06..4af1b53 100644 --- a/src/gameplay/core/Game.h +++ b/src/gameplay/core/Game.h @@ -15,7 +15,7 @@ enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT }; using Shape = std::array; // 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 }; diff --git a/src/gameplay/effects/LineEffect.cpp b/src/gameplay/effects/LineEffect.cpp index e918210..713718d 100644 --- a/src/gameplay/effects/LineEffect.cpp +++ b/src/gameplay/effects/LineEffect.cpp @@ -188,10 +188,13 @@ void LineEffect::initAudio() { } } -void LineEffect::startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize) { +void LineEffect::startLineClear(const std::vector& 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& 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(effectGapPx); + } SDL_Color tint = pickFireColor(); spawnGlowPulse(centerX, centerY, static_cast(blockSize), tint); spawnShardBurst(centerX, centerY, tint); @@ -337,8 +343,12 @@ 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: @@ -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(gridX - 4), static_cast(gridY + row * blockSize - 4), - static_cast(10 * blockSize + 8), + static_cast(effectGridCols * blockSize + gapW + 8), static_cast(blockSize + 8) }; SDL_RenderFillRect(renderer, &flashRect); diff --git a/src/gameplay/effects/LineEffect.h b/src/gameplay/effects/LineEffect.h index 99834ab..49a5263 100644 --- a/src/gameplay/effects/LineEffect.h +++ b/src/gameplay/effects/LineEffect.h @@ -69,11 +69,11 @@ public: void shutdown(); // Start line clear effect for the specified rows - void startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize); + void startLineClear(const std::vector& 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 rowDropTargets{}; float dropProgress = 0.0f; int dropBlockSize = 0; + int effectGridCols = Game::COLS; + int effectGapPx = 0; + int effectGapAfterCol = 0; }; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index d549a7e..ddd4c94 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -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 @@ -693,6 +697,11 @@ void GameRenderer::renderPlayingState( if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) { auto completedLines = game->getCompletedLines(); lineEffect->startLineClear(completedLines, static_cast(gridX), static_cast(gridY), static_cast(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(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(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(gridX), static_cast(gridY), static_cast(finalBlockSize), CoopGame::COLS, static_cast(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 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 s_leftSparkles; + static std::vector s_rightSparkles; + static std::vector s_leftImpactSparks; + static std::vector 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(sparkNow - s_lastCoopSparkTick); + s_lastCoopSparkTick = sparkNow; + if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) { + sparkDeltaMs = 16.0f; + } + + if (!s_starfieldInitialized) { + s_inGridStarfield.init(static_cast(halfW), static_cast(GRID_H), 180); + s_starfieldInitialized = true; + } else { + s_inGridStarfield.resize(static_cast(halfW), static_cast(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(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(filledCells), 0.0f, halfW); + info.y = std::clamp(sumLocalY / static_cast(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(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& sparkles, + std::vector& 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(0.0f, 1.0f)(s_coopSparkRng) > 0.35f); + + float sx = 0.0f; + float sy = 0.0f; + if (spawnNearPiece) { + float jitterX = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng); + float jitterY = std::uniform_real_distribution(-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(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(-borderBand, 0.0f)(s_coopSparkRng); + sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_coopSparkRng); + } else if (side < 0.4f) { + sx = std::uniform_real_distribution(halfW, halfW + borderBand)(s_coopSparkRng); + sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_coopSparkRng); + } else if (side < 0.6f) { + sx = std::uniform_real_distribution(-borderBand, halfW + borderBand)(s_coopSparkRng); + sy = std::uniform_real_distribution(-borderBand, 0.0f)(s_coopSparkRng); + } else if (side < 0.9f) { + sx = std::uniform_real_distribution(0.0f, halfW)(s_coopSparkRng); + sy = std::uniform_real_distribution(0.0f, finalBlockSize * 2.0f)(s_coopSparkRng); + } else { + sx = std::uniform_real_distribution(-borderBand, halfW + borderBand)(s_coopSparkRng); + sy = std::uniform_real_distribution(GRID_H, GRID_H + borderBand)(s_coopSparkRng); + } + } + + s.x = sx; + s.y = sy; + float speed = std::uniform_real_distribution(10.0f, 60.0f)(s_coopSparkRng); + float ang = std::uniform_real_distribution(-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(350.0f, 900.0f)(s_coopSparkRng); + s.lifeMs = s.maxLifeMs; + s.size = std::uniform_real_distribution(1.5f, 5.0f)(s_coopSparkRng); + if (std::uniform_real_distribution(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(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(4, 8)(s_coopSparkRng); + for (int bi = 0; bi < burstCount; ++bi) { + ImpactSpark ps; + ps.x = originX + sp.x + std::uniform_real_distribution(-2.0f, 2.0f)(s_coopSparkRng); + ps.y = gridY + sp.y + std::uniform_real_distribution(-2.0f, 2.0f)(s_coopSparkRng); + float ang = std::uniform_real_distribution(0.0f, 6.2831853f)(s_coopSparkRng); + float speed = std::uniform_real_distribution(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(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(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(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 impactMask{}; + std::array impactWeight{}; + if (game->hasHardDropShake()) { + impactStrength = static_cast(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((x * 37 + y * 61) % 113); + float t = static_cast(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(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(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(game->current(side).x); + } + + if (Settings::instance().isDebugEnabled()) { + float dbg_targetX = static_cast(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{ offsetX, offsetY }; + }; + + auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair& 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(nowTicks - sf.startTick); + float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f); + Uint8 alpha = static_cast(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(minCy) * sf.tileSize) + + static_cast(dySteps) * sf.tileSize + + offsets.second; + } else { + baseY = gridY + static_cast(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(cx) * sf.tileSize; + float py = baseY + static_cast(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& 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 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(gridX), static_cast(gridY), static_cast(finalBlockSize), static_cast(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 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::max(); + float statsContentBottom = std::numeric_limits::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(textH)); + statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast(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(lw) * 0.5f; + float valueX = centerX - static_cast(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, diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index f8d360c..04a69b2 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -3,6 +3,7 @@ #include #include #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. diff --git a/src/graphics/renderers/SyncLineRenderer.cpp b/src/graphics/renderers/SyncLineRenderer.cpp new file mode 100644 index 0000000..bf15bd6 --- /dev/null +++ b/src/graphics/renderers/SyncLineRenderer.cpp @@ -0,0 +1,358 @@ +#include "SyncLineRenderer.h" + +#include +#include +#include + +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(std::rand() % 17); + + p.x = centerX + jitter; + p.y = m_rect.y + m_rect.h + static_cast(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(std::rand() % 37)); + p.vy = 180.0f + static_cast(std::rand() % 180); + p.w = 1.0f + static_cast(std::rand() % 2); + p.h = 1.0f + static_cast(std::rand() % 2); + p.alpha = 240.0f; + } else { + p.vx = (-14.0f + static_cast(std::rand() % 29)); + p.vy = 160.0f + static_cast(std::rand() % 200); + p.w = 1.0f + static_cast(std::rand() % 3); + p.h = 3.0f + static_cast(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(std::min(255, static_cast(aura.r) + 10)); + aura.g = static_cast(std::min(255, static_cast(aura.g) + 10)); + aura.b = static_cast(std::min(255, static_cast(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(std::min(255, static_cast(auraOuter.r) + 10)); + auraOuter.g = static_cast(std::min(255, static_cast(auraOuter.g) + 5)); + auraOuter.b = static_cast(std::min(255, static_cast(auraOuter.b) + 55)); + + SDL_Color auraInner = aura; + auraInner.r = static_cast(std::min(255, static_cast(auraInner.r) + 40)); + auraInner.g = static_cast(std::min(255, static_cast(auraInner.g) + 40)); + auraInner.b = static_cast(std::min(255, static_cast(auraInner.b) + 70)); + + // Wider + softer outer halo, then tighter inner glow. + drawGlow(62.0f, static_cast(std::clamp(12.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter); + drawGlow(44.0f, static_cast(std::clamp(20.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter); + drawGlow(30.0f, static_cast(std::clamp(34.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter); + drawGlow(18.0f, static_cast(std::clamp(54.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner); + drawGlow(10.0f, static_cast(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(std::min(255, static_cast(hot.r) + 35)); + hot.g = static_cast(std::min(255, static_cast(hot.g) + 35)); + hot.b = static_cast(std::min(255, static_cast(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(std::clamp((m_state == SyncState::Synced ? 85.0f : 55.0f) * flashBoost, 0.0f, 255.0f)); + Uint8 ha2 = static_cast(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(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(i) / static_cast(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(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(std::clamp(175.0f + pulse01 * 75.0f, 0.0f, 255.0f)); + } + // Keep the center more translucent; let glow carry intensity. + bodyA = static_cast(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(std::clamp(bodyA * 0.60f, 0.0f, 255.0f))); + SDL_RenderFillRect(renderer, &left); + SDL_RenderFillRect(renderer, &right); + + SDL_SetRenderDrawColor(renderer, + static_cast(std::min(255, static_cast(color.r) + 35)), + static_cast(std::min(255, static_cast(color.g) + 35)), + static_cast(std::min(255, static_cast(color.b) + 55)), + static_cast(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(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(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(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(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); +} diff --git a/src/graphics/renderers/SyncLineRenderer.h b/src/graphics/renderers/SyncLineRenderer.h new file mode 100644 index 0000000..2a256fb --- /dev/null +++ b/src/graphics/renderers/SyncLineRenderer.h @@ -0,0 +1,54 @@ +#pragma once +#include + +#include + +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 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; +}; diff --git a/src/graphics/renderers/UIRenderer.cpp b/src/graphics/renderers/UIRenderer.cpp index 3007b0c..f6e976b 100644 --- a/src/graphics/renderers/UIRenderer.cpp +++ b/src/graphics/renderers/UIRenderer.cpp @@ -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}); } diff --git a/src/graphics/ui/HelpOverlay.cpp b/src/graphics/ui/HelpOverlay.cpp index 356ba59..fbe2059 100644 --- a/src/graphics/ui/HelpOverlay.cpp +++ b/src/graphics/ui/HelpOverlay.cpp @@ -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 menuShortcuts{{ diff --git a/src/network/supabase_client.cpp b/src/network/supabase_client.cpp new file mode 100644 index 0000000..51e8bbe --- /dev/null +++ b/src/network/supabase_client.cpp @@ -0,0 +1,182 @@ +#include "supabase_client.h" +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { +// Supabase constants (publishable anon key) +const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co"; +const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA"; + +std::string buildUrl(const std::string &path) { + std::string url = SUPABASE_URL; + if (!url.empty() && url.back() == '/') url.pop_back(); + url += "/rest/v1/" + path; + return url; +} + +size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t realSize = size * nmemb; + std::string *s = reinterpret_cast(userp); + s->append(reinterpret_cast(contents), realSize); + return realSize; +} + +struct CurlInit { + CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); } + ~CurlInit() { curl_global_cleanup(); } +}; +static CurlInit g_curl_init; +} + +namespace supabase { + +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(std::lround(entry.timeSec)); + j["name"] = entry.name; + j["game_type"] = entry.gameType; + j["timestamp"] = static_cast(std::time(nullptr)); + + std::string body = j.dump(); + struct curl_slist *headers = nullptr; + std::string h1 = std::string("apikey: ") + SUPABASE_ANON_KEY; + std::string h2 = std::string("Authorization: Bearer ") + SUPABASE_ANON_KEY; + headers = curl_slist_append(headers, h1.c_str()); + headers = curl_slist_append(headers, h2.c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + std::string resp; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + + 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 FetchHighscores(const std::string &gameType, int limit) { + std::vector 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(); + if (v.contains("lines")) e.lines = v["lines"].get(); + if (v.contains("level")) e.level = v["level"].get(); + if (v.contains("time_sec")) { + try { e.timeSec = v["time_sec"].get(); } catch(...) { e.timeSec = v["time_sec"].get(); } + } else if (v.contains("timestamp")) { + e.timeSec = v["timestamp"].get(); + } + if (v.contains("name")) e.name = v["name"].get(); + if (v.contains("game_type")) e.gameType = v["game_type"].get(); + out.push_back(e); + } + } + } catch (...) { + 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 diff --git a/src/network/supabase_client.h b/src/network/supabase_client.h new file mode 100644 index 0000000..93b5b22 --- /dev/null +++ b/src/network/supabase_client.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include "../persistence/Scores.h" + +namespace supabase { + +// Submit a highscore asynchronously (detached thread) +void SubmitHighscoreAsync(const ScoreEntry &entry); + +// Fetch highscores for a game type. If gameType is empty, fetch all (limited). +std::vector FetchHighscores(const std::string &gameType, int limit); + +// Enable or disable verbose logging to stderr. Disabled by default. +void SetVerbose(bool enabled); + +} // namespace supabase diff --git a/src/persistence/Scores.cpp b/src/persistence/Scores.cpp index c744b8e..5aa8229 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -1,20 +1,18 @@ -// Scores.cpp - Implementation of ScoreManager with Firebase Sync +// Scores.cpp - Implementation of ScoreManager #include "Scores.h" #include #include #include #include -#include +#include "../network/supabase_client.h" #include #include #include #include +#include using json = nlohmann::json; -// Firebase Realtime Database URL -const std::string FIREBASE_URL = "https://tetris-90139.firebaseio.com/scores.json"; - ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {} std::string ScoreManager::filePath() const { @@ -27,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 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& 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"} }; } diff --git a/src/persistence/Scores.h b/src/persistence/Scores.h index 1fede86..1f11e0b 100644 --- a/src/persistence/Scores.h +++ b/src/persistence/Scores.h @@ -3,14 +3,18 @@ #include #include -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& 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& all() const { return scores; } private: diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 40ce2dc..fc63209 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -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 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 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 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"}, diff --git a/src/states/MenuState.h b/src/states/MenuState.h index 7522975..2fa4f09 100644 --- a/src/states/MenuState.h +++ b/src/states/MenuState.h @@ -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; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 21fee29..e5268d0 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -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; } } @@ -172,7 +228,21 @@ 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) + ); + } } } diff --git a/src/states/State.h b/src/states/State.h index a9e0503..6775c4f 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -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; diff --git a/src/ui/BottomMenu.cpp b/src/ui/BottomMenu.cpp index 884c07d..18a25e1 100644 --- a/src/ui/BottomMenu.cpp +++ b/src/ui/BottomMenu.cpp @@ -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(std::round(180.0 * aMul)) }; SDL_Color bdCol{ 255, 200, 70, static_cast(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(std::round(190.0 * aMul)) }; + bdCol = SDL_Color{ 160, 210, 255, static_cast(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(std::round(190.0 * aMul)) }; bdCol = SDL_Color{ 120, 255, 220, static_cast(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(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) { diff --git a/src/ui/BottomMenu.h b/src/ui/BottomMenu.h index 3d1d1ee..75695e5 100644 --- a/src/ui/BottomMenu.h +++ b/src/ui/BottomMenu.h @@ -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, diff --git a/src/ui/MenuLayout.cpp b/src/ui/MenuLayout.cpp index 34e6148..d79b758 100644 --- a/src/ui/MenuLayout.cpp +++ b/src/ui/MenuLayout.cpp @@ -1,7 +1,8 @@ #include "ui/MenuLayout.h" #include "ui/UIConstants.h" -#include +#include #include +#include namespace ui { @@ -12,7 +13,7 @@ std::array 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 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(smallCount) + smallSpacing * static_cast(smallCount - 1); - float topRowTotal = playW * 2.0f + bigGap; + const int bigCount = 3; + const int smallCount = MENU_BTN_COUNT - bigCount; + float smallTotal = smallW * static_cast(smallCount) + smallSpacing * static_cast(std::max(smallCount - 1, 0)); + float topRowTotal = playW * static_cast(bigCount) + bigGap * static_cast(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 computeMenuButtonRects(const MenuLayoutPar float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f; std::array 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(bigCount) + bigGap * static_cast(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(smallCount) + smallSpacing * static_cast(smallCount - 1); float left = centerX - rowW * 0.5f; @@ -63,7 +67,7 @@ std::array 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; } diff --git a/src/ui/MenuLayout.h b/src/ui/MenuLayout.h index d185860..e93036e 100644 --- a/src/ui/MenuLayout.h +++ b/src/ui/MenuLayout.h @@ -17,7 +17,7 @@ struct MenuLayoutParams { std::array 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) diff --git a/src/ui/MenuWrappers.cpp b/src/ui/MenuWrappers.cpp index ceeb95c..121c162 100644 --- a/src/ui/MenuWrappers.cpp +++ b/src/ui/MenuWrappers.cpp @@ -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}); } diff --git a/src/ui/UIConstants.h b/src/ui/UIConstants.h index def95a5..88db763 100644 --- a/src/ui/UIConstants.h +++ b/src/ui/UIConstants.h @@ -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