From afd7fdf18d435b45682779d184fd4a81d280d855 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 15:33:37 +0100 Subject: [PATCH 01/23] basic gameplay for cooperative --- CMakeLists.txt | 1 + src/app/Fireworks.cpp | 3 + src/app/Fireworks.h | 1 + src/app/TetrisApp.cpp | 121 +++++- src/core/application/ApplicationManager.cpp | 213 +++++++--- src/core/application/ApplicationManager.h | 10 + src/gameplay/coop/CoopGame.cpp | 423 ++++++++++++++++++++ src/gameplay/coop/CoopGame.h | 143 +++++++ src/gameplay/core/Game.h | 2 +- src/graphics/renderers/GameRenderer.cpp | 381 ++++++++++++++++++ src/graphics/renderers/GameRenderer.h | 18 + src/states/MenuState.cpp | 25 +- src/states/MenuState.h | 2 +- src/states/PlayingState.cpp | 379 +++++++++++------- src/states/State.h | 2 + src/ui/BottomMenu.cpp | 26 +- src/ui/BottomMenu.h | 17 +- src/ui/MenuLayout.cpp | 26 +- src/ui/MenuLayout.h | 2 +- src/ui/UIConstants.h | 2 +- 20 files changed, 1534 insertions(+), 263 deletions(-) create mode 100644 src/gameplay/coop/CoopGame.cpp create mode 100644 src/gameplay/coop/CoopGame.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e8edcb3..34ee9ca 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 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..84013ee 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" @@ -228,6 +229,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 +244,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 +429,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"}; @@ -479,7 +489,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 +519,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; @@ -858,6 +872,9 @@ void TetrisApp::Impl::runLoop() 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 +910,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 +1177,88 @@ 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; + } + }; + + 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]; + + if (!game->isPaused()) { + coopGame->tickGravity(frameMs); + coopGame->updateVisualEffects(frameMs); + } + + if (coopGame->isGameOver()) { + 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(); + scores.submit(game->score(), game->lines(), game->level(), game->elapsed()); + } + state = AppState::GameOver; + stateMgr->setState(state); } - state = AppState::GameOver; - stateMgr->setState(state); } } else if (state == AppState::Loading) diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 89ea991..e0fa151 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(); @@ -1237,74 +1252,144 @@ 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) { + 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) { + m_stateContext.scores->submit( + m_stateContext.game->score(), + m_stateContext.game->lines(), + m_stateContext.game->level(), + m_stateContext.game->elapsed() + ); + } + 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..d19251b --- /dev/null +++ b/src/gameplay/coop/CoopGame.cpp @@ -0,0 +1,423 @@ +#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; + + 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.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; + Piece test = ps.cur; + test.rot = (test.rot + dir + 4) % 4; + // Simple wall kick: try in place, then left, then right + if (!collides(ps, test)) { + ps.cur = test; return; + } + test.x -= 1; + if (!collides(ps, test)) { ps.cur = test; return; } + test.x += 2; + if (!collides(ps, test)) { ps.cur = test; 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 + 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; + + 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.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; + return (mask >> (15 - 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(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; + } + 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(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); + + _lines += cleared; + + _currentCombo += 1; + if (_currentCombo > _maxCombo) _maxCombo = _currentCombo; + if (cleared > 1) { + _comboCount += 1; + } + if (cleared == 4) { + _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); + } +} + +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..6b50eb7 --- /dev/null +++ b/src/gameplay/coop/CoopGame.h @@ -0,0 +1,143 @@ +#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}; + std::vector bag{}; // 7-bag queue + std::mt19937 rng{ std::random_device{}() }; + }; + + explicit CoopGame(int startLevel = 0); + using SoundCallback = std::function; + void setSoundCallback(SoundCallback cb) { soundCallback = 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 lines() const { return _lines; } + int level() const { return _level; } + int comboCount() const { return _comboCount; } + int maxCombo() const { return _maxCombo; } + int tetrisesMade() const { return _tetrisesMade; } + 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}; + + 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; + + // 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(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/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index d549a7e..0cc1dab 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1,5 +1,7 @@ #include "GameRenderer.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 +695,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 +1363,24 @@ 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 +1831,362 @@ 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, + float logicalW, + float logicalH, + float logicalScale, + float winW, + float winH +) { + if (!renderer || !game || !pixelFont) return; + + 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 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; 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); + }; + + 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 maxBlockSizeW = availableWidth / 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 GRID_W = CoopGame::COLS * finalBlockSize; + 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 statsY = gridY; + const float statsW = PANEL_WIDTH; + const float statsH = GRID_H; + + // Shared score panel (reuse existing art) + SDL_FRect scorePanelBg{ statsX - 20.0f, gridY - 26.0f, statsW + 40.0f, GRID_H + 52.0f }; + if (statisticsPanelTex) { + SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &scorePanelBg); + } else if (scorePanelTex) { + SDL_RenderTexture(renderer, scorePanelTex, nullptr, &scorePanelBg); + } else { + drawRectWithOffset(scorePanelBg.x - contentOffsetX, scorePanelBg.y - contentOffsetY, scorePanelBg.w, scorePanelBg.h, SDL_Color{12,18,32,205}); + } + + // 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)); + if (completedLines.size() == 4) { + AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f); + } + } + + // Grid backdrop and border + drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); + drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); + + // Divider line between halves (between columns 9 and 10) + float dividerX = gridX + finalBlockSize * 10.0f; + SDL_SetRenderDrawColor(renderer, 180, 210, 255, 235); + SDL_FRect divider{ dividerX - 2.0f, gridY, 4.0f, GRID_H }; + SDL_RenderFillRect(renderer, ÷r); + SDL_SetRenderDrawColor(renderer, 40, 80, 150, 140); + SDL_FRect dividerGlow{ dividerX - 4.0f, gridY, 8.0f, GRID_H }; + SDL_RenderFillRect(renderer, ÷rGlow); + + // Grid lines + SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); + for (int x = 1; x < CoopGame::COLS; ++x) { + float lineX = gridX + 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 + GRID_W, lineY); + } + + // 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(); + for (int y = 0; y < CoopGame::ROWS; ++y) { + const auto& rs = rowStates[y]; + float rowY = gridY + y * finalBlockSize; + + if (rs.leftFull && rs.rightFull) { + SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45); + SDL_FRect fr{gridX, rowY, GRID_W, finalBlockSize}; + SDL_RenderFillRect(renderer, &fr); + } else if (rs.leftFull ^ rs.rightFull) { + SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35); + float w = GRID_W * 0.5f; + float x = rs.leftFull ? gridX : gridX + w; + SDL_FRect fr{x, rowY, w, finalBlockSize}; + SDL_RenderFillRect(renderer, &fr); + } + } + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + + // Draw settled blocks + const auto& board = game->boardRef(); + for (int y = 0; y < CoopGame::ROWS; ++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 = gridX + x * finalBlockSize; + float py = gridY + y * finalBlockSize; + 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.tileSize = finalBlockSize; + // Target to first visible row (row 0) + sf.targetX = gridX + static_cast(sf.piece.x) * finalBlockSize; + sf.targetY = gridY + 0.0f * finalBlockSize; + } 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_LogInfo(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 }; + }; + + // Draw any active spawn fades (alpha ramp into first row). Draw before + // the regular active pieces; while the spawn fade is active the piece's + // real position is above the grid and will not be drawn by drawPiece. + auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf) { + if (!sf.active) return; + Uint32 now = SDL_GetTicks(); + float elapsed = static_cast(now - 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); + // Draw piece at target (first row) + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!CoopGame::cellFilled(sf.piece, cx, cy)) continue; + float px = sf.targetX + static_cast(cx) * sf.tileSize; + float py = sf.targetY + static_cast(cy) * sf.tileSize; + drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, sf.piece.type); + } + } + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); + if (t >= 1.0f) sf.active = false; + }; + + auto drawPiece = [&](const CoopGame::Piece& p, CoopGame::PlayerSide side, const std::pair& offsets) { + 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 = gridX + (float)pxIdx * finalBlockSize + offsets.first; + float py = gridY + (float)pyIdx * finalBlockSize + offsets.second; + 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) into the first visible row + drawSpawnFadeIfActive(s_leftSpawnFade); + drawSpawnFadeIfActive(s_rightSpawnFade); + + // 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), CoopGame::PlayerSide::Left, leftOffsets); + } + if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) { + drawPiece(game->current(CoopGame::PlayerSide::Right), CoopGame::PlayerSide::Right, rightOffsets); + } + + // Next panels (two) + const float nextPanelPad = 12.0f; + const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f; + const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f; + float nextLeftX = gridX + finalBlockSize; + float nextRightX = gridX + GRID_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)); + + // Simple shared score text + char buf[128]; + std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", game->score(), game->lines(), game->level()); + pixelFont->draw(renderer, gridX + GRID_W * 0.5f - 140.0f, gridY + GRID_H + 24.0f, buf, 1.2f, SDL_Color{220, 230, 255, 255}); +} + void GameRenderer::renderExitPopup( SDL_Renderer* renderer, FontAtlas* pixelFont, diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index f8d360c..4143881 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,23 @@ 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, + 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/states/MenuState.cpp b/src/states/MenuState.cpp index 40ce2dc..6555d16 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -442,7 +442,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 +451,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 +470,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 +491,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 +503,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 +513,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 +524,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 +534,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; 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..6d07fa6 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,164 @@ 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; + } + + // 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; + } + if (e.key.scancode == SDL_SCANCODE_LSHIFT) { + 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; + } + if (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 +216,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 +262,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 +304,44 @@ 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, + 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 +350,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 +420,53 @@ 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, + 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/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 From 4efb60bb5be6545f024a50bb2ccdbcc7876a08c4 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 15:59:46 +0100 Subject: [PATCH 02/23] added ghost block --- src/graphics/renderers/GameRenderer.cpp | 50 +++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 0cc1dab..57d9dbe 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -2106,7 +2106,7 @@ void GameRenderer::renderCoopPlayingState( if (t >= 1.0f) sf.active = false; }; - auto drawPiece = [&](const CoopGame::Piece& p, CoopGame::PlayerSide side, const std::pair& offsets) { + 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; @@ -2115,7 +2115,17 @@ void GameRenderer::renderCoopPlayingState( if (pyIdx < 0) continue; // don't draw parts above the visible grid float px = gridX + (float)pxIdx * finalBlockSize + offsets.first; float py = gridY + (float)pyIdx * finalBlockSize + offsets.second; - drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type); + 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); + } } } }; @@ -2125,14 +2135,46 @@ void GameRenderer::renderCoopPlayingState( drawSpawnFadeIfActive(s_leftSpawnFade); drawSpawnFadeIfActive(s_rightSpawnFade); + // 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), CoopGame::PlayerSide::Left, leftOffsets); + 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), CoopGame::PlayerSide::Right, rightOffsets); + drawPiece(game->current(CoopGame::PlayerSide::Right), rightOffsets, false); } // Next panels (two) From cf3e8977528a3970ea7edfc50d222782b8112e76 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 16:25:09 +0100 Subject: [PATCH 03/23] added clear line effect --- src/gameplay/effects/LineEffect.cpp | 7 +++--- src/gameplay/effects/LineEffect.h | 3 ++- src/graphics/renderers/GameRenderer.cpp | 31 ++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/gameplay/effects/LineEffect.cpp b/src/gameplay/effects/LineEffect.cpp index e918210..0dc3570 100644 --- a/src/gameplay/effects/LineEffect.cpp +++ b/src/gameplay/effects/LineEffect.cpp @@ -188,10 +188,11 @@ 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) { if (rows.empty()) return; clearingRows = rows; + effectGridCols = std::max(1, gridCols); state = AnimationState::FLASH_WHITE; timer = 0.0f; dropProgress = 0.0f; @@ -228,7 +229,7 @@ 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; SDL_Color tint = pickFireColor(); spawnGlowPulse(centerX, centerY, static_cast(blockSize), tint); @@ -386,7 +387,7 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) { SDL_FRect flashRect = { static_cast(gridX - 4), static_cast(gridY + row * blockSize - 4), - static_cast(10 * blockSize + 8), + static_cast(effectGridCols * blockSize + 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..26fea94 100644 --- a/src/gameplay/effects/LineEffect.h +++ b/src/gameplay/effects/LineEffect.h @@ -69,7 +69,7 @@ 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); // Update and render the effect bool update(float deltaTime); // Returns true if effect is complete @@ -120,4 +120,5 @@ private: std::array rowDropTargets{}; float dropProgress = 0.0f; int dropBlockSize = 0; + int effectGridCols = Game::COLS; }; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 57d9dbe..3b55de9 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1926,12 +1926,18 @@ void GameRenderer::renderCoopPlayingState( // 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)); + lineEffect->startLineClear(completedLines, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize), CoopGame::COLS); 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 drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); @@ -1980,11 +1986,12 @@ void GameRenderer::renderCoopPlayingState( // 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 = gridX + x * finalBlockSize; - float py = gridY + y * finalBlockSize; + float py = gridY + y * finalBlockSize + dropOffset; drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1); } } @@ -2012,7 +2019,20 @@ void GameRenderer::renderCoopPlayingState( sf.tileSize = finalBlockSize; // Target to first visible row (row 0) sf.targetX = gridX + static_cast(sf.piece.x) * finalBlockSize; - sf.targetY = gridY + 0.0f * finalBlockSize; + // IMPORTANT: In classic mode, pieces can spawn with their first filled + // cell not at cy=0 within the 4x4. To avoid appearing one row too low + // (and then jumping up), align the topmost filled cell to row 0. + int minCy = 4; + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!CoopGame::cellFilled(sf.piece, cx, cy)) continue; + minCy = std::min(minCy, cy); + } + } + if (minCy == 4) { + minCy = 0; + } + sf.targetY = gridY - static_cast(minCy) * finalBlockSize; } else { // Reuse exact horizontal smoothing from single-player constexpr float HORIZONTAL_SMOOTH_MS = 55.0f; @@ -2177,6 +2197,11 @@ void GameRenderer::renderCoopPlayingState( 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)); + } + // Next panels (two) const float nextPanelPad = 12.0f; const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f; From 322744c2965fbb60646577529cc14bee8453aac1 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 16:31:23 +0100 Subject: [PATCH 04/23] fixed first row --- src/gameplay/coop/CoopGame.cpp | 49 +++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index d19251b..0179fce 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -104,16 +104,45 @@ void CoopGame::move(PlayerSide side, int dx) { void CoopGame::rotate(PlayerSide side, int dir) { PlayerState& ps = player(side); if (gameOver || ps.toppedOut) return; - Piece test = ps.cur; - test.rot = (test.rot + dir + 4) % 4; - // Simple wall kick: try in place, then left, then right - if (!collides(ps, test)) { - ps.cur = test; return; - } - test.x -= 1; - if (!collides(ps, test)) { ps.cur = test; return; } - test.x += 2; - if (!collides(ps, test)) { ps.cur = test; 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) { From e2d6ea64a40abf923fb50e1769967929f7b0707c Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 16:37:20 +0100 Subject: [PATCH 05/23] added hard drop --- src/states/PlayingState.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 6d07fa6..c783780 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -148,7 +148,8 @@ void PlayingState::handleEvent(const SDL_Event& e) { ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1); return; } - if (e.key.scancode == SDL_SCANCODE_LSHIFT) { + // 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; @@ -168,7 +169,8 @@ void PlayingState::handleEvent(const SDL_Event& e) { ctx.coopGame->rotate(CoopGame::PlayerSide::Right, -1); return; } - if (e.key.scancode == SDL_SCANCODE_RSHIFT) { + // 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; From ab22d4c34fd6bfa4dfcfb3640f6ec3b05f2cda2c Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 17:04:46 +0100 Subject: [PATCH 06/23] hard drop shake effect added --- src/graphics/renderers/GameRenderer.cpp | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 3b55de9..20e6cad 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1983,6 +1983,39 @@ void GameRenderer::renderCoopPlayingState( } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + // 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) { @@ -1992,6 +2025,18 @@ void GameRenderer::renderCoopPlayingState( if (!cell.occupied || cell.value <= 0) continue; float px = gridX + x * finalBlockSize; 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); } } From b46af7ab1d6433265c1bd7cf9a1e6ce505886dc9 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 17:22:30 +0100 Subject: [PATCH 07/23] removed s shortcut for sound fx toggle --- settings.ini | 2 +- src/app/TetrisApp.cpp | 3 +- src/core/application/ApplicationManager.cpp | 4 +- src/graphics/renderers/GameRenderer.cpp | 224 ++++++++++++++++++++ src/graphics/renderers/GameRenderer.h | 1 + src/graphics/renderers/UIRenderer.cpp | 2 +- src/graphics/ui/HelpOverlay.cpp | 2 +- src/states/MenuState.cpp | 2 +- src/states/PlayingState.cpp | 2 + src/ui/MenuWrappers.cpp | 2 +- 10 files changed, 236 insertions(+), 8 deletions(-) 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/TetrisApp.cpp b/src/app/TetrisApp.cpp index 84013ee..10432e8 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -775,7 +775,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()); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index e0fa151..5c0fde3 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -932,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()); } } diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 20e6cad..ed4c304 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1841,6 +1841,7 @@ void GameRenderer::renderCoopPlayingState( SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, SDL_Texture* holdPanelTex, + bool paused, float logicalW, float logicalH, float logicalScale, @@ -1962,6 +1963,229 @@ void GameRenderer::renderCoopPlayingState( SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_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 = GRID_W * 0.5f; + const float leftGridX = gridX; + const float rightGridX = gridX + halfW; + + 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(); diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index 4143881..04a69b2 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -72,6 +72,7 @@ public: SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, SDL_Texture* holdPanelTex, + bool paused, float logicalW, float logicalH, float logicalScale, 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/states/MenuState.cpp b/src/states/MenuState.cpp index 6555d16..a4c8e43 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -1248,7 +1248,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/PlayingState.cpp b/src/states/PlayingState.cpp index c783780..4416b9f 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -322,6 +322,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.scorePanelTex, ctx.nextPanelTex, ctx.holdPanelTex, + paused, 1200.0f, 1000.0f, logicalScale, @@ -438,6 +439,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.scorePanelTex, ctx.nextPanelTex, ctx.holdPanelTex, + paused, 1200.0f, 1000.0f, logicalScale, 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}); } From a9943ce8bf1c3fb224fce9a9f94cc4621bf9d77a Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 17:26:53 +0100 Subject: [PATCH 08/23] when clearing lines play voices --- src/app/TetrisApp.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 10432e8..e76fffb 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -468,6 +468,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; From 06aa63f548d22896fe33374a92ddc575acb3f108 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 17:52:07 +0100 Subject: [PATCH 09/23] fixed score display --- src/gameplay/coop/CoopGame.cpp | 39 ++++++- src/gameplay/coop/CoopGame.h | 17 ++- src/graphics/renderers/GameRenderer.cpp | 136 +++++++++++++++++++++--- 3 files changed, 175 insertions(+), 17 deletions(-) diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index 0179fce..0d8abf8 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -55,6 +55,7 @@ void CoopGame::reset(int startLevel_) { gravityMs = gravityMsForLevel(_level); gameOver = false; pieceSequence = 0; + elapsedMs = 0.0; left = PlayerState{}; right = PlayerState{ PlayerSide::Right }; @@ -67,6 +68,13 @@ void CoopGame::reset(int startLevel_) { 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); @@ -169,6 +177,7 @@ void CoopGame::hardDrop(PlayerSide side) { } if (moved) { _score += dropped; // 1 point per cell, matches single-player hard drop + ps.score += dropped; hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS; hardDropFxId++; } @@ -198,6 +207,8 @@ void CoopGame::holdCurrent(PlayerSide side) { 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; @@ -214,6 +225,7 @@ void CoopGame::tickGravity(double frameMs) { // Award soft drop points when actively holding down if (ps.softDropping) { _score += 1; + ps.score += 1; } ps.lockAcc = 0.0; } @@ -349,13 +361,14 @@ void CoopGame::lock(PlayerState& ps) { findCompletedLines(); if (!completedLines.empty()) { int cleared = static_cast(completedLines.size()); - applyLineClearRewards(cleared); + 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); } @@ -379,7 +392,7 @@ void CoopGame::findCompletedLines() { } } -void CoopGame::applyLineClearRewards(int cleared) { +void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) { if (cleared <= 0) return; // Base NES scoring scaled by shared level (level 0 => 1x multiplier) @@ -392,8 +405,10 @@ void CoopGame::applyLineClearRewards(int cleared) { default: base = 0; break; } _score += base * (_level + 1); + creditPlayer.score += base * (creditPlayer.level + 1); _lines += cleared; + creditPlayer.lines += cleared; _currentCombo += 1; if (_currentCombo > _maxCombo) _maxCombo = _currentCombo; @@ -404,6 +419,15 @@ void CoopGame::applyLineClearRewards(int cleared) { _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; @@ -414,6 +438,17 @@ void CoopGame::applyLineClearRewards(int cleared) { _level = targetLevel; gravityMs = gravityMsForLevel(_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() { diff --git a/src/gameplay/coop/CoopGame.h b/src/gameplay/coop/CoopGame.h index 6b50eb7..331bed5 100644 --- a/src/gameplay/coop/CoopGame.h +++ b/src/gameplay/coop/CoopGame.h @@ -44,6 +44,13 @@ public: 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{}() }; }; @@ -71,11 +78,17 @@ public: 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; } @@ -113,6 +126,8 @@ private: double gravityGlobalMultiplier{1.0}; bool gameOver{false}; + double elapsedMs{0.0}; + std::vector completedLines; // Impact FX @@ -136,7 +151,7 @@ private: void findCompletedLines(); void clearLinesInternal(); void updateRowStates(); - void applyLineClearRewards(int cleared); + 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/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index ed4c304..1869064 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1369,6 +1369,7 @@ void GameRenderer::renderPlayingState( 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, @@ -1379,6 +1380,7 @@ void GameRenderer::renderPlayingState( sp_fallAcc, sp_soft ); + */ } // Draw ghost piece (where current piece will land) @@ -1910,19 +1912,13 @@ void GameRenderer::renderCoopPlayingState( 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; - // Shared score panel (reuse existing art) - SDL_FRect scorePanelBg{ statsX - 20.0f, gridY - 26.0f, statsW + 40.0f, GRID_H + 52.0f }; - if (statisticsPanelTex) { - SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &scorePanelBg); - } else if (scorePanelTex) { - SDL_RenderTexture(renderer, scorePanelTex, nullptr, &scorePanelBg); - } else { - drawRectWithOffset(scorePanelBg.x - contentOffsetX, scorePanelBg.y - contentOffsetY, scorePanelBg.w, scorePanelBg.h, SDL_Color{12,18,32,205}); - } + // (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()) { @@ -2357,7 +2353,8 @@ void GameRenderer::renderCoopPlayingState( double gMsDbg = game->getGravityMs(); double accDbg = game->getFallAccumulator(side); int softDbg = game->isSoftDropping(side) ? 1 : 0; - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d", + /* + 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, @@ -2368,6 +2365,7 @@ void GameRenderer::renderCoopPlayingState( accDbg, softDbg ); + */ } return std::pair{ offsetX, offsetY }; }; @@ -2517,10 +2515,120 @@ void GameRenderer::renderCoopPlayingState( drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left)); drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right)); - // Simple shared score text - char buf[128]; - std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", game->score(), game->lines(), game->level()); - pixelFont->draw(renderer, gridX + GRID_W * 0.5f - 140.0f, gridY + GRID_H + 24.0f, buf, 1.2f, SDL_Color{220, 230, 255, 255}); + // 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"); } void GameRenderer::renderExitPopup( From 744268fedd8324ec1a54f85d23832fba0d992b62 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 17:59:21 +0100 Subject: [PATCH 10/23] added pause option coop gameplay --- src/app/TetrisApp.cpp | 24 +++++++++++++++------ src/core/application/ApplicationManager.cpp | 13 +++++++++++ src/states/PlayingState.cpp | 10 +++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index e76fffb..62da545 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -1231,15 +1231,25 @@ void TetrisApp::Impl::runLoop() } }; - 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); + 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]; + p1LeftHeld = ks[SDL_SCANCODE_A]; + p1RightHeld = ks[SDL_SCANCODE_D]; + p2LeftHeld = ks[SDL_SCANCODE_LEFT]; + p2RightHeld = ks[SDL_SCANCODE_RIGHT]; - if (!game->isPaused()) { coopGame->tickGravity(frameMs); coopGame->updateVisualEffects(frameMs); } diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 5c0fde3..9f2c2e6 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -1259,6 +1259,19 @@ void ApplicationManager::setupStateHandlers() { const bool *ks = SDL_GetKeyboardState(nullptr); 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, diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 4416b9f..e5268d0 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -133,6 +133,16 @@ void PlayingState::handleEvent(const SDL_Event& e) { 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; From 33d5eedec8c32e9184c2addaa5871a3e719d5296 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 18:11:21 +0100 Subject: [PATCH 11/23] fixed I block in coop mode --- src/graphics/renderers/GameRenderer.cpp | 66 ++++++++++++++++--------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 1869064..09a82ac 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -2282,22 +2282,10 @@ void GameRenderer::renderCoopPlayingState( sf.seq = seq; sf.piece = game->current(side); sf.tileSize = finalBlockSize; - // Target to first visible row (row 0) + // Note: targetX/targetY are recomputed during drawing using the live + // current piece so movement/rotation during the fade stays correct. sf.targetX = gridX + static_cast(sf.piece.x) * finalBlockSize; - // IMPORTANT: In classic mode, pieces can spawn with their first filled - // cell not at cy=0 within the 4x4. To avoid appearing one row too low - // (and then jumping up), align the topmost filled cell to row 0. - int minCy = 4; - for (int cy = 0; cy < 4; ++cy) { - for (int cx = 0; cx < 4; ++cx) { - if (!CoopGame::cellFilled(sf.piece, cx, cy)) continue; - minCy = std::min(minCy, cy); - } - } - if (minCy == 4) { - minCy = 0; - } - sf.targetY = gridY - static_cast(minCy) * finalBlockSize; + sf.targetY = gridY; } else { // Reuse exact horizontal smoothing from single-player constexpr float HORIZONTAL_SMOOTH_MS = 55.0f; @@ -2373,24 +2361,54 @@ void GameRenderer::renderCoopPlayingState( // Draw any active spawn fades (alpha ramp into first row). Draw before // the regular active pieces; while the spawn fade is active the piece's // real position is above the grid and will not be drawn by drawPiece. - auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf) { + auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side) { if (!sf.active) return; - Uint32 now = SDL_GetTicks(); - float elapsed = static_cast(now - sf.startTick); + + // 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); - // Draw piece at target (first row) + + // Align the topmost filled cell to row 0 (first visible row), like classic. + int minCy = 4; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { - if (!CoopGame::cellFilled(sf.piece, cx, cy)) continue; + if (!CoopGame::cellFilled(livePiece, cx, cy)) continue; + minCy = std::min(minCy, cy); + } + } + if (minCy == 4) { + minCy = 0; + } + sf.targetX = gridX + static_cast(livePiece.x) * sf.tileSize; + sf.targetY = gridY - static_cast(minCy) * sf.tileSize; + + // Draw the live piece at the fade target. + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!CoopGame::cellFilled(livePiece, cx, cy)) continue; float px = sf.targetX + static_cast(cx) * sf.tileSize; float py = sf.targetY + static_cast(cy) * sf.tileSize; - drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, sf.piece.type); + drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type); } } if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); - if (t >= 1.0f) sf.active = false; + + // Critical: only deactivate once the real piece is actually drawable. + // Otherwise (notably for I spawning at y=-2) there can be a brief gap where + // the fade ends but the real piece is still fully above the visible grid. + const bool pieceHasAnyVisibleCell = (livePiece.y + minCy) >= 0; + if (t >= 1.0f && pieceHasAnyVisibleCell) { + sf.active = false; + } }; auto drawPiece = [&](const CoopGame::Piece& p, const std::pair& offsets, bool isGhost) { @@ -2419,8 +2437,8 @@ void GameRenderer::renderCoopPlayingState( const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth); const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth); // Draw transient spawn fades (if active) into the first visible row - drawSpawnFadeIfActive(s_leftSpawnFade); - drawSpawnFadeIfActive(s_rightSpawnFade); + drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left); + drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right); // Draw classic-style ghost pieces (landing position), grid-aligned. // This intentionally does NOT use smoothing offsets. From 0b99911f5d1cc8b26c14aaa2cdd5000e2c2a325f Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 18:43:40 +0100 Subject: [PATCH 12/23] fixed i block --- src/gameplay/coop/CoopGame.cpp | 3 +- src/graphics/renderers/GameRenderer.cpp | 67 ++++++++++++++++--------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index 0d8abf8..8b29b4c 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -258,7 +258,8 @@ bool CoopGame::cellFilled(const Piece& p, int cx, int cy) { const Shape& shape = SHAPES[p.type]; uint16_t mask = shape[p.rot % 4]; int bitIndex = cy * 4 + cx; - return (mask >> (15 - bitIndex)) & 1; + // Masks are defined row-major 4x4 with bit 0 = (0,0) (same convention as classic). + return (mask >> bitIndex) & 1; } void CoopGame::clearCompletedLines() { diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 09a82ac..c845404 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1864,7 +1864,7 @@ void GameRenderer::renderCoopPlayingState( 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; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; }; + 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{}; @@ -2281,11 +2281,15 @@ void GameRenderer::renderCoopPlayingState( sf.durationMs = 200.0f; sf.seq = seq; sf.piece = game->current(side); + sf.spawnY = sf.piece.y; sf.tileSize = finalBlockSize; - // Note: targetX/targetY are recomputed during drawing using the live - // current piece so movement/rotation during the fade stays correct. - sf.targetX = gridX + static_cast(sf.piece.x) * finalBlockSize; - sf.targetY = gridY; + // 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; @@ -2358,10 +2362,7 @@ void GameRenderer::renderCoopPlayingState( return std::pair{ offsetX, offsetY }; }; - // Draw any active spawn fades (alpha ramp into first row). Draw before - // the regular active pieces; while the spawn fade is active the piece's - // real position is above the grid and will not be drawn by drawPiece. - auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side) { + auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair& offsets) { if (!sf.active) return; // If the piece has already changed, stop the fade. @@ -2377,36 +2378,56 @@ void GameRenderer::renderCoopPlayingState( Uint8 alpha = static_cast(std::lround(255.0f * t)); if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha); - // Align the topmost filled cell to row 0 (first visible row), like classic. 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; } - sf.targetX = gridX + static_cast(livePiece.x) * sf.tileSize; - sf.targetY = gridY - static_cast(minCy) * sf.tileSize; + if (maxCy < 0) { + maxCy = 0; + } - // Draw the live piece at the fade target. + // 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 = gridX + static_cast(livePiece.x) * sf.tileSize + 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; - float px = sf.targetX + static_cast(cx) * sf.tileSize; - float py = sf.targetY + static_cast(cy) * sf.tileSize; + 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); - // Critical: only deactivate once the real piece is actually drawable. - // Otherwise (notably for I spawning at y=-2) there can be a brief gap where - // the fade ends but the real piece is still fully above the visible grid. - const bool pieceHasAnyVisibleCell = (livePiece.y + minCy) >= 0; - if (t >= 1.0f && pieceHasAnyVisibleCell) { + // 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; } }; @@ -2436,9 +2457,9 @@ void GameRenderer::renderCoopPlayingState( }; const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth); const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth); - // Draw transient spawn fades (if active) into the first visible row - drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left); - drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right); + // 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. From 50c869536d634973a296a568ca4a1a63913ed2e8 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 19:45:20 +0100 Subject: [PATCH 13/23] highscore fixes --- src/app/TetrisApp.cpp | 91 ++++++++++++++++++--- src/core/application/ApplicationManager.cpp | 24 ++++-- src/gameplay/coop/CoopGame.cpp | 7 ++ src/graphics/renderers/GameRenderer.cpp | 28 +++++++ src/persistence/Scores.cpp | 10 ++- src/persistence/Scores.h | 6 +- src/states/MenuState.cpp | 18 ++-- 7 files changed, 155 insertions(+), 29 deletions(-) diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 62da545..cbb0e90 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -172,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; @@ -866,22 +868,54 @@ 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); + 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(); + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName); + 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) { @@ -1255,6 +1289,21 @@ void TetrisApp::Impl::runLoop() } 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()); + } state = AppState::GameOver; stateMgr->setState(state); } @@ -1974,13 +2023,29 @@ 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}); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 9f2c2e6..5a3c766 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -1232,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}); diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index 8b29b4c..bcaf8b6 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -408,6 +408,13 @@ void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) { _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; creditPlayer.lines += cleared; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index c845404..eb6b732 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -2668,6 +2668,34 @@ void GameRenderer::renderCoopPlayingState( 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( diff --git a/src/persistence/Scores.cpp b/src/persistence/Scores.cpp index c744b8e..3232ad4 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -42,6 +42,7 @@ void ScoreManager::load() { if (value.contains("level")) e.level = value["level"]; if (value.contains("timeSec")) e.timeSec = value["timeSec"]; if (value.contains("name")) e.name = value["name"]; + if (value.contains("game_type")) e.gameType = value["game_type"].get(); scores.push_back(e); } } @@ -54,6 +55,7 @@ void ScoreManager::load() { if (value.contains("level")) e.level = value["level"]; if (value.contains("timeSec")) e.timeSec = value["timeSec"]; if (value.contains("name")) e.name = value["name"]; + if (value.contains("game_type")) e.gameType = value["game_type"].get(); scores.push_back(e); } } @@ -92,6 +94,8 @@ void ScoreManager::load() { if (!remaining.empty() && remaining[0] == ' ') { e.name = remaining.substr(1); // Remove leading space } + // For backward compatibility local files may not include gameType; default is 'classic' + e.gameType = "classic"; scores.push_back(e); } if (scores.size() >= maxEntries) break; @@ -108,11 +112,12 @@ 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}); std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;}); @@ -131,6 +136,7 @@ void ScoreManager::submit(int score, int lines, int level, double timeSec, const j["timeSec"] = timeSec; j["name"] = name; j["timestamp"] = std::time(nullptr); // Add timestamp + j["game_type"] = gameType; // Fire and forget (async) would be better, but for now let's just try to send // We can use std::thread to make it async diff --git a/src/persistence/Scores.h b/src/persistence/Scores.h index 1fede86..1daa7cc 100644 --- a/src/persistence/Scores.h +++ b/src/persistence/Scores.h @@ -3,14 +3,16 @@ #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"); + // New optional `gameType` parameter will be sent to Firebase 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 a4c8e43..1a96e49 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -813,7 +813,13 @@ 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 + // Filter highscores to show only classic gameplay entries on the main menu + std::vector filtered; + filtered.reserve(hs.size()); + for (const auto &e : hs) { + if (e.gameType == "classic") 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) { @@ -899,18 +905,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); } From 494f90643561f06e0f1413625a55e433034543a7 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 20:50:44 +0100 Subject: [PATCH 14/23] supabase integration instead firebase --- CMakeLists.txt | 1 + src/network/supabase_client.cpp | 168 ++++++++++++++++++++++++++++++++ src/network/supabase_client.h | 14 +++ src/persistence/Scores.cpp | 112 ++++++--------------- src/persistence/Scores.h | 2 +- src/states/MenuState.cpp | 9 +- 6 files changed, 221 insertions(+), 85 deletions(-) create mode 100644 src/network/supabase_client.cpp create mode 100644 src/network/supabase_client.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 34ee9ca..af8d82e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ set(TETRIS_SOURCES src/core/Settings.cpp src/graphics/renderers/RenderManager.cpp src/persistence/Scores.cpp + src/network/supabase_client.cpp src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield3D.cpp src/graphics/effects/SpaceWarp.cpp diff --git a/src/network/supabase_client.cpp b/src/network/supabase_client.cpp new file mode 100644 index 0000000..1b43630 --- /dev/null +++ b/src/network/supabase_client.cpp @@ -0,0 +1,168 @@ +#include "supabase_client.h" +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { +// Supabase constants (publishable anon key) +const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co"; +const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA"; + +std::string buildUrl(const std::string &path) { + std::string url = SUPABASE_URL; + if (!url.empty() && url.back() == '/') url.pop_back(); + url += "/rest/v1/" + path; + return url; +} + +size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t realSize = size * nmemb; + std::string *s = reinterpret_cast(userp); + s->append(reinterpret_cast(contents), realSize); + return realSize; +} + +struct CurlInit { + CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); } + ~CurlInit() { curl_global_cleanup(); } +}; +static CurlInit g_curl_init; +} + +namespace supabase { + +void SubmitHighscoreAsync(const ScoreEntry &entry) { + std::thread([entry]() { + try { + CURL* curl = curl_easy_init(); + if (!curl) return; + + std::string url = buildUrl("highscores"); + + json j; + j["score"] = entry.score; + j["lines"] = entry.lines; + j["level"] = entry.level; + j["time_sec"] = static_cast(std::lround(entry.timeSec)); + j["name"] = entry.name; + j["game_type"] = entry.gameType; + j["timestamp"] = static_cast(std::time(nullptr)); + + std::string body = j.dump(); + struct curl_slist *headers = nullptr; + std::string h1 = std::string("apikey: ") + SUPABASE_ANON_KEY; + std::string h2 = std::string("Authorization: Bearer ") + SUPABASE_ANON_KEY; + headers = curl_slist_append(headers, h1.c_str()); + headers = curl_slist_append(headers, h2.c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + std::string resp; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + + // Debug: print outgoing request + std::cerr << "[Supabase] POST " << url << "\n"; + std::cerr << "[Supabase] Body: " << body << "\n"; + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n"; + } else { + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + std::cerr << "[Supabase] POST response code: " << http_code << " body_len=" << resp.size() << "\n"; + if (!resp.empty()) std::cerr << "[Supabase] POST response: " << resp << "\n"; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } catch (...) { + // swallow errors + } + }).detach(); +} + +std::vector FetchHighscores(const std::string &gameType, int limit) { + std::vector out; + try { + CURL* curl = curl_easy_init(); + if (!curl) return out; + + std::string path = "highscores"; + std::string query; + if (!gameType.empty()) { + if (gameType == "challenge") { + query = "?game_type=eq." + gameType + "&order=level.desc,time_sec.asc&limit=" + std::to_string(limit); + } else { + query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(limit); + } + } else { + query = "?order=score.desc&limit=" + std::to_string(limit); + } + + std::string url = buildUrl(path) + query; + + struct curl_slist *headers = nullptr; + headers = curl_slist_append(headers, ("apikey: " + SUPABASE_ANON_KEY).c_str()); + headers = curl_slist_append(headers, ("Authorization: Bearer " + SUPABASE_ANON_KEY).c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + + std::string resp; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + + // Debug: print outgoing GET + std::cerr << "[Supabase] GET " << url << "\n"; + + CURLcode res = curl_easy_perform(curl); + if (res == CURLE_OK) { + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + std::cerr << "[Supabase] GET response code: " << http_code << " body_len=" << resp.size() << "\n"; + if (!resp.empty()) std::cerr << "[Supabase] GET response: " << resp << "\n"; + try { + auto j = json::parse(resp); + if (j.is_array()) { + for (auto &v : j) { + ScoreEntry e{}; + if (v.contains("score")) e.score = v["score"].get(); + if (v.contains("lines")) e.lines = v["lines"].get(); + if (v.contains("level")) e.level = v["level"].get(); + if (v.contains("time_sec")) { + try { e.timeSec = v["time_sec"].get(); } catch(...) { e.timeSec = v["time_sec"].get(); } + } else if (v.contains("timestamp")) { + e.timeSec = v["timestamp"].get(); + } + if (v.contains("name")) e.name = v["name"].get(); + if (v.contains("game_type")) e.gameType = v["game_type"].get(); + out.push_back(e); + } + } + } catch (...) { + std::cerr << "[Supabase] GET parse error" << std::endl; + } + } else { + std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n"; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } catch (...) { + // swallow + } + return out; +} + +} // namespace supabase diff --git a/src/network/supabase_client.h b/src/network/supabase_client.h new file mode 100644 index 0000000..82e9fde --- /dev/null +++ b/src/network/supabase_client.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include +#include "../persistence/Scores.h" + +namespace supabase { + +// Submit a highscore asynchronously (detached thread) +void SubmitHighscoreAsync(const ScoreEntry &entry); + +// Fetch highscores for a game type. If gameType is empty, fetch all (limited). +std::vector FetchHighscores(const std::string &gameType, int limit); + +} // namespace supabase diff --git a/src/persistence/Scores.cpp b/src/persistence/Scores.cpp index 3232ad4..b92178e 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -1,20 +1,18 @@ -// Scores.cpp - Implementation of ScoreManager with Firebase Sync +// Scores.cpp - Implementation of ScoreManager #include "Scores.h" #include #include #include #include -#include +#include "../network/supabase_client.h" #include #include #include #include +#include using json = nlohmann::json; -// Firebase Realtime Database URL -const std::string FIREBASE_URL = "https://tetris-90139.firebaseio.com/scores.json"; - ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {} std::string ScoreManager::filePath() const { @@ -27,50 +25,18 @@ std::string ScoreManager::filePath() const { void ScoreManager::load() { scores.clear(); - // Try to load from Firebase first + // Try to load from Supabase first try { - cpr::Response r = cpr::Get(cpr::Url{FIREBASE_URL}, cpr::Timeout{2000}); // 2s timeout - if (r.status_code == 200 && !r.text.empty() && r.text != "null") { - auto j = json::parse(r.text); - - // Firebase returns a map of auto-generated IDs to objects - if (j.is_object()) { - for (auto& [key, value] : j.items()) { - ScoreEntry e; - if (value.contains("score")) e.score = value["score"]; - if (value.contains("lines")) e.lines = value["lines"]; - if (value.contains("level")) e.level = value["level"]; - if (value.contains("timeSec")) e.timeSec = value["timeSec"]; - if (value.contains("name")) e.name = value["name"]; - if (value.contains("game_type")) e.gameType = value["game_type"].get(); - scores.push_back(e); - } - } - // Or it might be an array if keys are integers (unlikely for Firebase push) - else if (j.is_array()) { - for (auto& value : j) { - ScoreEntry e; - if (value.contains("score")) e.score = value["score"]; - if (value.contains("lines")) e.lines = value["lines"]; - if (value.contains("level")) e.level = value["level"]; - if (value.contains("timeSec")) e.timeSec = value["timeSec"]; - if (value.contains("name")) e.name = value["name"]; - if (value.contains("game_type")) e.gameType = value["game_type"].get(); - scores.push_back(e); - } - } - - // Sort and keep top scores + auto fetched = supabase::FetchHighscores("", static_cast(maxEntries)); + if (!fetched.empty()) { + scores = fetched; std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;}); if (scores.size() > maxEntries) scores.resize(maxEntries); - - // Save to local cache save(); return; } } catch (...) { - // Ignore network errors and fall back to local file - std::cerr << "Failed to load from Firebase, falling back to local file." << std::endl; + std::cerr << "Failed to load from Supabase, falling back to local file." << std::endl; } // Fallback to local file @@ -119,37 +85,19 @@ void ScoreManager::save() const { void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name, const std::string& gameType) { // Add to local list - scores.push_back(ScoreEntry{score,lines,level,timeSec, name}); + ScoreEntry newEntry{}; + newEntry.score = score; + newEntry.lines = lines; + newEntry.level = level; + newEntry.timeSec = timeSec; + newEntry.name = name; + scores.push_back(newEntry); std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;}); if (scores.size()>maxEntries) scores.resize(maxEntries); save(); - - // Submit to Firebase - // Run in a detached thread to avoid blocking the UI? - // For simplicity, we'll do it blocking for now, or rely on short timeout. - // Ideally this should be async. - - json j; - j["score"] = score; - j["lines"] = lines; - j["level"] = level; - j["timeSec"] = timeSec; - j["name"] = name; - j["timestamp"] = std::time(nullptr); // Add timestamp - j["game_type"] = gameType; - - // Fire and forget (async) would be better, but for now let's just try to send - // We can use std::thread to make it async - std::thread([j]() { - try { - cpr::Post(cpr::Url{FIREBASE_URL}, - cpr::Body{j.dump()}, - cpr::Header{{"Content-Type", "application/json"}}, - cpr::Timeout{5000}); - } catch (...) { - // Ignore errors - } - }).detach(); + // Submit to Supabase asynchronously + ScoreEntry se{score, lines, level, timeSec, name, gameType}; + supabase::SubmitHighscoreAsync(se); } bool ScoreManager::isHighScore(int score) const { @@ -159,17 +107,17 @@ bool ScoreManager::isHighScore(int score) const { void ScoreManager::createSampleScores() { scores = { - {159840, 189, 14, 972, "GREGOR"}, - {156340, 132, 12, 714, "GREGOR"}, - {155219, 125, 12, 696, "GREGOR"}, - {141823, 123, 10, 710, "GREGOR"}, - {140079, 71, 11, 410, "GREGOR"}, - {116012, 121, 10, 619, "GREGOR"}, - {112643, 137, 13, 689, "GREGOR"}, - {99190, 61, 10, 378, "GREGOR"}, - {93648, 107, 10, 629, "GREGOR"}, - {89041, 115, 10, 618, "GREGOR"}, - {88600, 55, 9, 354, "GREGOR"}, - {86346, 141, 13, 723, "GREGOR"} + {159840, 189, 14, 972.0, "GREGOR"}, + {156340, 132, 12, 714.0, "GREGOR"}, + {155219, 125, 12, 696.0, "GREGOR"}, + {141823, 123, 10, 710.0, "GREGOR"}, + {140079, 71, 11, 410.0, "GREGOR"}, + {116012, 121, 10, 619.0, "GREGOR"}, + {112643, 137, 13, 689.0, "GREGOR"}, + {99190, 61, 10, 378.0, "GREGOR"}, + {93648, 107, 10, 629.0, "GREGOR"}, + {89041, 115, 10, 618.0, "GREGOR"}, + {88600, 55, 9, 354.0, "GREGOR"}, + {86346, 141, 13, 723.0, "GREGOR"} }; } diff --git a/src/persistence/Scores.h b/src/persistence/Scores.h index 1daa7cc..e08bfee 100644 --- a/src/persistence/Scores.h +++ b/src/persistence/Scores.h @@ -10,7 +10,7 @@ public: explicit ScoreManager(size_t maxScores = 12); void load(); void save() const; - // New optional `gameType` parameter will be sent to Firebase as `game_type`. + // New optional `gameType` parameter will be sent as `game_type`. // Allowed values: "classic", "versus", "cooperate", "challenge". void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER", const std::string& gameType = "classic"); bool isHighScore(int score) const; diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 1a96e49..7ba7338 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -813,11 +813,16 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi } static const std::vector EMPTY_SCORES; const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES; - // Filter highscores to show only classic gameplay entries on the main menu + // Choose which game_type to show based on current menu selection + std::string wantedType = "classic"; + if (selectedButton == 0) wantedType = "classic"; // Play / Endless + else if (selectedButton == 1) wantedType = "cooperate"; // Coop + else if (selectedButton == 2) wantedType = "challenge"; // Challenge + // Filter highscores to the desired game type std::vector filtered; filtered.reserve(hs.size()); for (const auto &e : hs) { - if (e.gameType == "classic") filtered.push_back(e); + if (e.gameType == wantedType) filtered.push_back(e); } size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10 From fb82ac06d0ec8f7177a9f3a70ee77ff8b8ecde4e Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 21:17:58 +0100 Subject: [PATCH 15/23] fixed highscores --- src/app/TetrisApp.cpp | 12 +- src/core/application/ApplicationManager.cpp | 5 +- src/persistence/Scores.cpp | 40 +++- src/persistence/Scores.h | 2 + src/states/MenuState.cpp | 19 ++ supabe_integrate.md | 213 ++++++++++++++++++++ 6 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 supabe_integrate.md diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index cbb0e90..9a60d4f 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -899,7 +899,7 @@ void TetrisApp::Impl::runLoop() int rightScore = coopGame->score(CoopGame::PlayerSide::Right); int combinedScore = leftScore + rightScore; ensureScoresLoaded(); - scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); Settings::instance().setPlayerName(playerName); isNewHighScore = false; SDL_StopTextInput(window); @@ -911,7 +911,8 @@ void TetrisApp::Impl::runLoop() } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { if (playerName.empty()) playerName = "PLAYER"; ensureScoresLoaded(); - scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName); + std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName, gt); Settings::instance().setPlayerName(playerName); isNewHighScore = false; SDL_StopTextInput(window); @@ -1302,7 +1303,7 @@ void TetrisApp::Impl::runLoop() } else { isNewHighScore = false; ensureScoresLoaded(); - scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed()); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate"); } state = AppState::GameOver; stateMgr->setState(state); @@ -1328,7 +1329,10 @@ void TetrisApp::Impl::runLoop() } else { isNewHighScore = false; ensureScoresLoaded(); - scores.submit(game->score(), game->lines(), game->level(), game->elapsed()); + { + std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt); + } } state = AppState::GameOver; stateMgr->setState(state); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 5a3c766..f919208 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -1406,11 +1406,14 @@ void ApplicationManager::setupStateHandlers() { 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() + m_stateContext.game->elapsed(), + std::string("PLAYER"), + gt ); } m_stateManager->setState(AppState::GameOver); diff --git a/src/persistence/Scores.cpp b/src/persistence/Scores.cpp index b92178e..04602d9 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -54,14 +54,33 @@ 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"; } - // For backward compatibility local files may not include gameType; default is 'classic' - e.gameType = "classic"; scores.push_back(e); } if (scores.size() >= maxEntries) break; @@ -91,6 +110,8 @@ void ScoreManager::submit(int score, int lines, int level, double timeSec, const newEntry.level = level; newEntry.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); @@ -105,6 +126,15 @@ 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.0, "GREGOR"}, diff --git a/src/persistence/Scores.h b/src/persistence/Scores.h index e08bfee..1f11e0b 100644 --- a/src/persistence/Scores.h +++ b/src/persistence/Scores.h @@ -10,6 +10,8 @@ public: explicit ScoreManager(size_t maxScores = 12); void load(); void save() const; + // 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"); diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 7ba7338..c486b84 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", 12); + auto c_coop = supabase::FetchHighscores("cooperate", 12); + auto c_challenge = supabase::FetchHighscores("challenge", 12); + 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) { diff --git a/supabe_integrate.md b/supabe_integrate.md new file mode 100644 index 0000000..3540c98 --- /dev/null +++ b/supabe_integrate.md @@ -0,0 +1,213 @@ +# Spacetris — Supabase Highscore Integration +## VS Code Copilot AI Agent Prompt + +You are integrating Supabase highscores into a native C++ SDL3 game called **Spacetris**. + +This is a REST-only integration using Supabase PostgREST. +Do NOT use any Supabase JS SDKs. + +--- + +## 1. Goal + +Implement a highscore backend using Supabase for these game modes: +- classic +- challenge +- cooperate +- versus + +Highscores must be: +- Submitted asynchronously on game over +- Fetched asynchronously for leaderboard screens +- Non-blocking (never stall render loop) +- Offline-safe (fail silently) + +--- + +## 2. Supabase Configuration + +The following constants are provided at build time: + +```cpp +const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co"; +const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA"; +```` + +All requests go to: + +``` +{SUPABASE_URL}/rest/v1/highscores +``` + +--- + +## 3. Database Schema (Already Exists) + +The Supabase table `highscores` has the following fields: + +* score (integer) +* lines (integer) +* level (integer) +* time_sec (integer) +* name (string) +* game_type ("classic", "versus", "cooperate", "challenge") +* timestamp (integer, UNIX epoch seconds) + +--- + +## 4. Data Model in C++ + +Create a struct matching the database schema: + +```cpp +struct HighscoreEntry { + int score; + int lines; + int level; + int timeSec; + std::string name; + std::string gameType; + int timestamp; +}; +``` + +--- + +## 5. HTTP Layer Requirements + +* Use **libcurl** +* Use **JSON** (nlohmann::json or equivalent) +* All network calls must run in a worker thread +* Never block the SDL main loop + +Required HTTP headers: + +``` +apikey: SUPABASE_ANON_KEY +Authorization: Bearer SUPABASE_ANON_KEY +Content-Type: application/json +``` + +--- + +## 6. Submit Highscore (POST) + +Implement: + +```cpp +void SubmitHighscoreAsync(const HighscoreEntry& entry); +``` + +Behavior: + +* Convert entry to JSON +* POST to `/rest/v1/highscores` +* On failure: + + * Log error + * Do NOT crash + * Optionally store JSON locally for retry + +Example JSON payload: + +```json +{ + "score": 123456, + "lines": 240, + "level": 37, + "time_sec": 1820, + "name": "P1 & P2", + "game_type": "cooperate", + "timestamp": 1710000000 +} +``` + +--- + +## 7. Fetch Leaderboard (GET) + +Implement: + +```cpp +std::vector FetchHighscores( + const std::string& gameType, + int limit +); +``` + +REST query examples: + +Classic: + +``` +?game_type=eq.classic&order=score.desc&limit=20 +``` + +Challenge: + +``` +?game_type=eq.challenge&order=level.desc,time_sec.asc&limit=20 +``` + +Cooperate: + +``` +?game_type=eq.cooperate&order=score.desc&limit=20 +``` + +--- + +## 8. Threading Model + +* Use `std::thread` or a simple job queue +* Network calls must not run on the render thread +* Use mutex or lock-free queue to pass results back to UI + +--- + +## 9. Error Handling Rules + +* If Supabase is unreachable: + + * Game continues normally + * Leaderboard screen shows "Offline" +* Never block gameplay +* Never show raw network errors to player + +--- + +## 10. Security Constraints + +* API key is public (acceptable for highscores) +* Obfuscate key in binary if possible +* Do NOT trust client-side data (future server validation planned) + +--- + +## 11. File Structure Suggestion + +``` +/network + supabase_client.h + supabase_client.cpp + +/highscores + highscore_submit.cpp + highscore_fetch.cpp +``` + +--- + +## 12. Acceptance Criteria + +* Highscores are submitted after game over +* Leaderboards load without blocking gameplay +* Works for all four game types +* Offline mode does not crash or freeze +* Code is clean, modular, and SDL3-safe + +--- + +## 13. Summary for the Agent + +Integrate Supabase highscores into Spacetris using REST calls from C++ with libcurl. Use async submission and fetching. Support classic, challenge, cooperate, and versus modes. Ensure non-blocking behavior and graceful offline handling. From 70946fc72045b4816c461b49cad431476249326f Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 21:33:31 +0100 Subject: [PATCH 16/23] fixed highscores --- src/app/TetrisApp.cpp | 101 ++++++++++++++++++++------------ src/network/supabase_client.cpp | 44 +++++++++----- src/network/supabase_client.h | 3 + src/persistence/Scores.cpp | 3 +- src/states/MenuState.cpp | 10 ++-- 5 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 9a60d4f..589e797 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -2054,21 +2054,15 @@ void TetrisApp::Impl::runLoop() 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}); - 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; @@ -2077,34 +2071,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/network/supabase_client.cpp b/src/network/supabase_client.cpp index 1b43630..51e8bbe 100644 --- a/src/network/supabase_client.cpp +++ b/src/network/supabase_client.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include using json = nlohmann::json; @@ -35,6 +36,13 @@ 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 { @@ -68,18 +76,21 @@ void SubmitHighscoreAsync(const ScoreEntry &entry) { curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); - // Debug: print outgoing request - std::cerr << "[Supabase] POST " << url << "\n"; - std::cerr << "[Supabase] Body: " << body << "\n"; + 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) { - std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n"; + 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); - std::cerr << "[Supabase] POST response code: " << http_code << " body_len=" << resp.size() << "\n"; - if (!resp.empty()) std::cerr << "[Supabase] POST response: " << resp << "\n"; + 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); @@ -97,15 +108,17 @@ std::vector FetchHighscores(const std::string &gameType, int limit) 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(limit); + 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(limit); + query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(l); } } else { - query = "?order=score.desc&limit=" + std::to_string(limit); + query = "?order=score.desc&limit=" + std::to_string(l); } std::string url = buildUrl(path) + query; @@ -123,15 +136,16 @@ std::vector FetchHighscores(const std::string &gameType, int limit) curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); - // Debug: print outgoing GET - std::cerr << "[Supabase] GET " << url << "\n"; + 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); - std::cerr << "[Supabase] GET response code: " << http_code << " body_len=" << resp.size() << "\n"; - if (!resp.empty()) std::cerr << "[Supabase] GET response: " << resp << "\n"; + 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()) { @@ -151,10 +165,10 @@ std::vector FetchHighscores(const std::string &gameType, int limit) } } } catch (...) { - std::cerr << "[Supabase] GET parse error" << std::endl; + if (g_verbose) std::cerr << "[Supabase] GET parse error" << std::endl; } } else { - std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n"; + if (g_verbose) std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n"; } curl_slist_free_all(headers); diff --git a/src/network/supabase_client.h b/src/network/supabase_client.h index 82e9fde..93b5b22 100644 --- a/src/network/supabase_client.h +++ b/src/network/supabase_client.h @@ -11,4 +11,7 @@ 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 04602d9..5aa8229 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -27,7 +27,8 @@ void ScoreManager::load() { // Try to load from Supabase first try { - auto fetched = supabase::FetchHighscores("", static_cast(maxEntries)); + // 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;}); diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index c486b84..54f8b6d 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -174,9 +174,9 @@ void MenuState::onEnter() { try { std::thread([this]() { try { - auto c_classic = supabase::FetchHighscores("classic", 12); - auto c_coop = supabase::FetchHighscores("cooperate", 12); - auto c_challenge = supabase::FetchHighscores("challenge", 12); + 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()); @@ -801,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 From 60ddc9ddd380afdfd2189db94373e38467a02d9c Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 21:37:04 +0100 Subject: [PATCH 17/23] fixed name entry --- src/app/TetrisApp.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 589e797..36d01b2 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -2057,7 +2057,9 @@ void TetrisApp::Impl::runLoop() 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}); + } const float inputW = isCoopEntry ? 260.0f : 300.0f; const float inputH = 40.0f; From 694243ac890bbf56993226120b4b6373a23f4993 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Mon, 22 Dec 2025 13:09:36 +0100 Subject: [PATCH 18/23] fixed highscore in main menu --- src/states/MenuState.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 54f8b6d..fc63209 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -849,7 +849,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi // 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; @@ -864,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; @@ -878,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); From 18463774e98dca6693d4f83695aea9bb61d81be5 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Mon, 22 Dec 2025 13:48:54 +0100 Subject: [PATCH 19/23] fixed for cooperate mode --- Spacetris-COOPERATE Mode.md | 293 +++++++++++++++++++++++++++++++++ src/app/TetrisApp.cpp | 11 ++ src/gameplay/coop/CoopGame.cpp | 5 +- src/gameplay/coop/CoopGame.h | 3 + 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 Spacetris-COOPERATE Mode.md diff --git a/Spacetris-COOPERATE Mode.md b/Spacetris-COOPERATE Mode.md new file mode 100644 index 0000000..ab625fb --- /dev/null +++ b/Spacetris-COOPERATE Mode.md @@ -0,0 +1,293 @@ +# Spacetris — COOPERATE Mode +## Middle SYNC Line Effect (SDL3) +### VS Code Copilot AI Agent Integration Guide + +> Goal: Implement a **visual SYNC divider line** in COOPERATE mode that communicates cooperation state between two players. +> This effect must be lightweight, performant, and decoupled from gameplay logic. + +--- + +## 1. Concept Overview + +The **SYNC Line** is the vertical divider between the two halves of the COOPERATE grid. + +It must: +- Always be visible +- React when one side is ready +- Pulse when both sides are ready +- Flash on line clear +- Provide instant, text-free feedback + +No shaders. No textures. SDL3 only. + +--- + +## 2. Visual States + +Define these states: + +```cpp +enum class SyncState { + Idle, // no side ready + LeftReady, // left half complete + RightReady, // right half complete + Synced, // both halves complete + ClearFlash // row cleared +}; +```` + +--- + +## 3. Color Language + +| State | Color | Meaning | +| ------------------ | --------------- | ------------------- | +| Idle | Blue | Neutral | +| Left / Right Ready | Yellow | Waiting for partner | +| Synced | Green (pulsing) | Perfect cooperation | +| ClearFlash | White | Successful clear | + +--- + +## 4. Geometry + +The SYNC line is a simple rectangle: + +```cpp +SDL_FRect syncLine; +syncLine.x = gridCenterX - 2; +syncLine.y = gridTopY; +syncLine.w = 4; +syncLine.h = gridHeight; +``` + +--- + +## 5. Rendering Rules + +* Always render every frame +* Use alpha blending +* Pulse alpha when synced +* Flash briefly on clear + +--- + +## 6. SyncLineRenderer Class (Required) + +### Header + +```cpp +#pragma once +#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: + SDL_FRect m_rect; + SyncState m_state; + + float m_flashTimer; + float m_time; + + static constexpr float FLASH_DURATION = 0.15f; + + SDL_Color GetBaseColor() const; +}; +``` + +--- + +### Implementation + +```cpp +#include "SyncLineRenderer.h" +#include + +SyncLineRenderer::SyncLineRenderer() + : m_state(SyncState::Idle), + m_flashTimer(0.0f), + m_time(0.0f) {} + +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; +} + +void SyncLineRenderer::Update(float deltaTime) { + m_time += deltaTime; + + 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 {255, 220, 100, 200}; // yellow + + case SyncState::Synced: + return {100, 255, 120, 220}; // green + + case SyncState::ClearFlash: + return {255, 255, 255, 255}; // white + + default: + return {80, 180, 255, 180}; // idle blue + } +} + +void SyncLineRenderer::Render(SDL_Renderer* renderer) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + SDL_Color color = GetBaseColor(); + + if (m_state == SyncState::Synced) { + float pulse = 0.5f + 0.5f * std::sinf(m_time * 6.0f); + color.a = static_cast(160 + pulse * 80); + } + + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + SDL_RenderFillRect(renderer, &m_rect); + + if (m_state == SyncState::ClearFlash) { + SDL_FRect glow = m_rect; + glow.x -= 3; + glow.w += 6; + + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 180); + SDL_RenderFillRect(renderer, &glow); + } +} +``` + +--- + +## 7. Integration Flow + +### Initialization + +```cpp +SyncLineRenderer syncLine; +syncLine.SetRect({ + gridCenterX - 2.0f, + gridTopY, + 4.0f, + gridHeight +}); +``` + +--- + +### Update Loop + +```cpp +syncLine.Update(deltaTime); +``` + +--- + +### Game Logic → Sync State Mapping + +```cpp +if (leftRowReady && rightRowReady) + syncLine.SetState(SyncState::Synced); +else if (leftRowReady) + syncLine.SetState(SyncState::LeftReady); +else if (rightRowReady) + syncLine.SetState(SyncState::RightReady); +else + syncLine.SetState(SyncState::Idle); +``` + +--- + +### On Line Clear Event + +```cpp +syncLine.TriggerClearFlash(); +``` + +--- + +### Render Loop + +```cpp +syncLine.Render(renderer); +``` + +--- + +## 8. Performance Requirements + +* ≤ 2 draw calls per frame +* No textures +* No dynamic allocations +* No blocking logic +* Suitable for 60–240 FPS + +--- + +## 9. UX Rationale + +* Color communicates state without text +* Pulse indicates readiness +* Flash provides satisfaction on success +* Reinforces cooperation visually + +--- + +## 10. Acceptance Criteria + +* Divider is always visible +* State changes are instantly readable +* Flash triggers exactly on cooperative line clear +* No performance impact +* Clean separation from gameplay logic + +--- + +## 11. Optional Future Enhancements (Not Required) + +* Vertical energy particles +* Sound hooks on SYNC / CLEAR +* Combo-based intensity +* Color-blind palette +* Gradient or glow variants + +--- + +## Summary for Copilot + +Implement a `SyncLineRenderer` class in SDL3 to render the cooperative divider line. The line reflects cooperation state through color, pulse, and flash effects. The renderer must be lightweight, stateless with gameplay, and fully driven by external state updates. diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 36d01b2..594f979 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -494,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); }); diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index bcaf8b6..aad5d23 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -416,7 +416,9 @@ void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) { } _lines += cleared; - creditPlayer.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; @@ -445,6 +447,7 @@ void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) { if (targetLevel > _level) { _level = targetLevel; gravityMs = gravityMsForLevel(_level); + if (levelUpCallback) levelUpCallback(_level); } // Per-player level progression mirrors the shared rules but is driven by diff --git a/src/gameplay/coop/CoopGame.h b/src/gameplay/coop/CoopGame.h index 331bed5..3377a50 100644 --- a/src/gameplay/coop/CoopGame.h +++ b/src/gameplay/coop/CoopGame.h @@ -57,7 +57,9 @@ public: 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); @@ -137,6 +139,7 @@ private: uint32_t hardDropFxId{0}; uint64_t pieceSequence{0}; SoundCallback soundCallback; + LevelUpCallback levelUpCallback; // Helpers --------------------------------------------------------------- PlayerState& player(PlayerSide s) { return s == PlayerSide::Left ? left : right; } From a729dc089e2c6a010cfb25828733cd2ea3cb7c62 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Mon, 22 Dec 2025 17:13:35 +0100 Subject: [PATCH 20/23] sync line added in cooperate mode --- CMakeLists.txt | 1 + src/gameplay/effects/LineEffect.cpp | 16 ++- src/gameplay/effects/LineEffect.h | 6 +- src/graphics/renderers/GameRenderer.cpp | 114 +++++++++++++++----- src/graphics/renderers/SyncLineRenderer.cpp | 79 ++++++++++++++ src/graphics/renderers/SyncLineRenderer.h | 33 ++++++ 6 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 src/graphics/renderers/SyncLineRenderer.cpp create mode 100644 src/graphics/renderers/SyncLineRenderer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index af8d82e..2882987 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ set(TETRIS_SOURCES 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/src/gameplay/effects/LineEffect.cpp b/src/gameplay/effects/LineEffect.cpp index 0dc3570..713718d 100644 --- a/src/gameplay/effects/LineEffect.cpp +++ b/src/gameplay/effects/LineEffect.cpp @@ -188,11 +188,13 @@ void LineEffect::initAudio() { } } -void LineEffect::startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize, int gridCols) { +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; @@ -231,6 +233,9 @@ 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 < 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); @@ -338,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: @@ -384,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(effectGridCols * 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 26fea94..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, int gridCols = Game::COLS); + 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 @@ -121,4 +121,6 @@ private: 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 eb6b732..da3b07a 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1,4 +1,6 @@ #include "GameRenderer.h" + +#include "SyncLineRenderer.h" #include "../../gameplay/core/Game.h" #include "../../gameplay/coop/CoopGame.h" #include "../../app/Fireworks.h" @@ -1852,6 +1854,9 @@ void GameRenderer::renderCoopPlayingState( ) { 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); @@ -1860,6 +1865,9 @@ void GameRenderer::renderCoopPlayingState( 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{}; @@ -1889,15 +1897,19 @@ void GameRenderer::renderCoopPlayingState( SDL_RenderFillRect(renderer, &fr); }; + static constexpr float COOP_GAP_PX = 10.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 maxBlockSizeW = availableWidth / CoopGame::COLS; + 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 GRID_W = CoopGame::COLS * finalBlockSize; + 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; @@ -1923,7 +1935,7 @@ void GameRenderer::renderCoopPlayingState( // 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); + 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); } @@ -1935,28 +1947,39 @@ void GameRenderer::renderCoopPlayingState( rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f); } - // Grid backdrop and border + // 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}); - // Divider line between halves (between columns 9 and 10) - float dividerX = gridX + finalBlockSize * 10.0f; - SDL_SetRenderDrawColor(renderer, 180, 210, 255, 235); - SDL_FRect divider{ dividerX - 2.0f, gridY, 4.0f, GRID_H }; - SDL_RenderFillRect(renderer, ÷r); - SDL_SetRenderDrawColor(renderer, 40, 80, 150, 140); - SDL_FRect dividerGlow{ dividerX - 4.0f, gridY, 8.0f, GRID_H }; - SDL_RenderFillRect(renderer, ÷rGlow); + // 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 }); - // Grid lines + 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 < CoopGame::COLS; ++x) { + 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 + GRID_W, lineY); + 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) @@ -1970,9 +1993,9 @@ void GameRenderer::renderCoopPlayingState( static float s_leftSparkleSpawnAcc = 0.0f; static float s_rightSparkleSpawnAcc = 0.0f; - float halfW = GRID_W * 0.5f; + float halfW = HALF_W; const float leftGridX = gridX; - const float rightGridX = gridX + halfW; + const float rightGridX = gridX + HALF_W + COOP_GAP_PX; Uint32 sparkNow = nowTicks; float sparkDeltaMs = static_cast(sparkNow - s_lastCoopSparkTick); @@ -2185,24 +2208,57 @@ void GameRenderer::renderCoopPlayingState( // 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 fr{gridX, rowY, GRID_W, finalBlockSize}; - SDL_RenderFillRect(renderer, &fr); + 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 = GRID_W * 0.5f; - float x = rs.leftFull ? gridX : gridX + w; + 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; @@ -2243,7 +2299,7 @@ void GameRenderer::renderCoopPlayingState( 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 = gridX + x * finalBlockSize; + float px = cellX(x); float py = gridY + y * finalBlockSize + dropOffset; const int cellIdx = y * CoopGame::COLS + x; @@ -2398,7 +2454,7 @@ void GameRenderer::renderCoopPlayingState( // cases like vertical I where some blocks are already visible at spawn. const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0; - const float baseX = gridX + static_cast(livePiece.x) * sf.tileSize + offsets.first; + 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 @@ -2439,7 +2495,7 @@ void GameRenderer::renderCoopPlayingState( int pxIdx = p.x + cx; int pyIdx = p.y + cy; if (pyIdx < 0) continue; // don't draw parts above the visible grid - float px = gridX + (float)pxIdx * finalBlockSize + offsets.first; + float px = cellX(pxIdx) + offsets.first; float py = gridY + (float)pyIdx * finalBlockSize + offsets.second; if (isGhost) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); @@ -2505,15 +2561,19 @@ void GameRenderer::renderCoopPlayingState( // 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)); + 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 = (GRID_W * 0.5f) - finalBlockSize * 1.5f; + const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f; const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f; float nextLeftX = gridX + finalBlockSize; - float nextRightX = gridX + GRID_W - finalBlockSize - nextPanelW; + 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) { diff --git a/src/graphics/renderers/SyncLineRenderer.cpp b/src/graphics/renderers/SyncLineRenderer.cpp new file mode 100644 index 0000000..675a735 --- /dev/null +++ b/src/graphics/renderers/SyncLineRenderer.cpp @@ -0,0 +1,79 @@ +#include "SyncLineRenderer.h" + +#include + +SyncLineRenderer::SyncLineRenderer() + : m_state(SyncState::Idle), + m_flashTimer(0.0f), + m_time(0.0f) {} + +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; +} + +void SyncLineRenderer::Update(float deltaTime) { + m_time += deltaTime; + + 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; + } + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + SDL_Color color = GetBaseColor(); + + if (m_state == SyncState::Synced) { + float pulse = 0.5f + 0.5f * std::sinf(m_time * 6.0f); + color.a = static_cast(180.0f + pulse * 75.0f); + } + + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + SDL_RenderFillRect(renderer, &m_rect); + + if (m_state == SyncState::ClearFlash) { + SDL_FRect glow = m_rect; + glow.x -= 3; + glow.w += 6; + + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 180); + SDL_RenderFillRect(renderer, &glow); + } +} diff --git a/src/graphics/renderers/SyncLineRenderer.h b/src/graphics/renderers/SyncLineRenderer.h new file mode 100644 index 0000000..3d529d2 --- /dev/null +++ b/src/graphics/renderers/SyncLineRenderer.h @@ -0,0 +1,33 @@ +#pragma once +#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: + SDL_FRect m_rect{}; + SyncState m_state; + + float m_flashTimer; + float m_time; + + static constexpr float FLASH_DURATION = 0.15f; + + SDL_Color GetBaseColor() const; +}; From d3ca238a5145b45798a02ddf1f55fd612ddadcaf Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Mon, 22 Dec 2025 17:18:29 +0100 Subject: [PATCH 21/23] updated sync line --- src/graphics/renderers/SyncLineRenderer.cpp | 95 ++++++++++++++++++++- src/graphics/renderers/SyncLineRenderer.h | 16 ++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/graphics/renderers/SyncLineRenderer.cpp b/src/graphics/renderers/SyncLineRenderer.cpp index 675a735..ddf3153 100644 --- a/src/graphics/renderers/SyncLineRenderer.cpp +++ b/src/graphics/renderers/SyncLineRenderer.cpp @@ -1,11 +1,33 @@ #include "SyncLineRenderer.h" +#include #include +#include SyncLineRenderer::SyncLineRenderer() : m_state(SyncState::Idle), m_flashTimer(0.0f), - m_time(0.0f) {} + m_time(0.0f) { + m_particles.reserve(MAX_PARTICLES); +} + +void SyncLineRenderer::SpawnParticle() { + if (m_particles.size() >= MAX_PARTICLES) { + return; + } + + SyncParticle p; + p.y = m_rect.y + m_rect.h; + p.speed = 120.0f + static_cast(std::rand() % 80); + p.alpha = 180.0f; + 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; @@ -20,10 +42,48 @@ void SyncLineRenderer::SetState(SyncState state) { void SyncLineRenderer::TriggerClearFlash() { m_state = SyncState::ClearFlash; m_flashTimer = FLASH_DURATION; + + // Reward burst: strong visual feedback on cooperative clear. + SpawnBurst(20); } void SyncLineRenderer::Update(float deltaTime) { m_time += deltaTime; + m_pulseTime += deltaTime; + + // State-driven particle spawning + float spawnRatePerSec = 0.0f; + switch (m_state) { + case SyncState::LeftReady: + case SyncState::RightReady: + spawnRatePerSec = 3.0f; // slow/occasional + break; + case SyncState::Synced: + spawnRatePerSec = 14.0f; // continuous + break; + default: + spawnRatePerSec = 0.0f; + break; + } + + if (spawnRatePerSec <= 0.0f) { + m_spawnAcc = 0.0f; + } else { + m_spawnAcc += deltaTime * spawnRatePerSec; + while (m_spawnAcc >= 1.0f) { + m_spawnAcc -= 1.0f; + SpawnParticle(); + } + } + + // Update particles + for (auto& p : m_particles) { + p.y -= p.speed * deltaTime; + p.alpha -= 120.0f * deltaTime; + } + std::erase_if(m_particles, [&](const SyncParticle& p) { + return p.y < m_rect.y || p.alpha <= 0.0f; + }); if (m_state == SyncState::ClearFlash) { m_flashTimer -= deltaTime; @@ -58,6 +118,23 @@ void SyncLineRenderer::Render(SDL_Renderer* renderer) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + // 1) Pulse wave (Synced only) + if (m_state == SyncState::Synced) { + float wave = std::fmod(m_pulseTime * 2.0f, 1.0f); + float width = 4.0f + wave * 12.0f; + Uint8 alpha = static_cast(120.0f * (1.0f - wave)); + + SDL_FRect waveRect{ + m_rect.x - width * 0.5f, + m_rect.y, + m_rect.w + width, + m_rect.h + }; + + SDL_SetRenderDrawColor(renderer, 100, 255, 120, alpha); + SDL_RenderFillRect(renderer, &waveRect); + } + SDL_Color color = GetBaseColor(); if (m_state == SyncState::Synced) { @@ -65,9 +142,25 @@ void SyncLineRenderer::Render(SDL_Renderer* renderer) { color.a = static_cast(180.0f + pulse * 75.0f); } + // 2) Base SYNC line SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); SDL_RenderFillRect(renderer, &m_rect); + // 3) Energy particles + for (const auto& p : m_particles) { + SDL_FRect dot{ + m_rect.x - 3.0f, + p.y, + m_rect.w + 6.0f, + 3.0f + }; + + Uint8 a = static_cast(std::clamp(p.alpha, 0.0f, 255.0f)); + SDL_SetRenderDrawColor(renderer, 120, 255, 180, a); + SDL_RenderFillRect(renderer, &dot); + } + + // 4) Flash/glow overlay if (m_state == SyncState::ClearFlash) { SDL_FRect glow = m_rect; glow.x -= 3; diff --git a/src/graphics/renderers/SyncLineRenderer.h b/src/graphics/renderers/SyncLineRenderer.h index 3d529d2..f8b1d25 100644 --- a/src/graphics/renderers/SyncLineRenderer.h +++ b/src/graphics/renderers/SyncLineRenderer.h @@ -1,6 +1,8 @@ #pragma once #include +#include + enum class SyncState { Idle, LeftReady, @@ -21,13 +23,27 @@ public: void Render(SDL_Renderer* renderer); private: + struct SyncParticle { + float y; + float speed; + float alpha; + }; + 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 = 64; + + void SpawnParticle(); + void SpawnBurst(int count); SDL_Color GetBaseColor() const; }; From 3c9dc0ff65d03fe388d54a3c032c590d9156b6f7 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Mon, 22 Dec 2025 18:49:06 +0100 Subject: [PATCH 22/23] update visually --- src/graphics/renderers/GameRenderer.cpp | 2 +- src/graphics/renderers/SyncLineRenderer.cpp | 262 +++++++++++++++++--- src/graphics/renderers/SyncLineRenderer.h | 9 +- 3 files changed, 232 insertions(+), 41 deletions(-) diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index da3b07a..ddd4c94 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1897,7 +1897,7 @@ void GameRenderer::renderCoopPlayingState( SDL_RenderFillRect(renderer, &fr); }; - static constexpr float COOP_GAP_PX = 10.0f; + 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; diff --git a/src/graphics/renderers/SyncLineRenderer.cpp b/src/graphics/renderers/SyncLineRenderer.cpp index ddf3153..bf15bd6 100644 --- a/src/graphics/renderers/SyncLineRenderer.cpp +++ b/src/graphics/renderers/SyncLineRenderer.cpp @@ -11,15 +11,51 @@ SyncLineRenderer::SyncLineRenderer() 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; - p.y = m_rect.y + m_rect.h; - p.speed = 120.0f + static_cast(std::rand() % 80); - p.alpha = 180.0f; + 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); } @@ -44,7 +80,7 @@ void SyncLineRenderer::TriggerClearFlash() { m_flashTimer = FLASH_DURATION; // Reward burst: strong visual feedback on cooperative clear. - SpawnBurst(20); + SpawnBurst(56); } void SyncLineRenderer::Update(float deltaTime) { @@ -53,16 +89,18 @@ void SyncLineRenderer::Update(float deltaTime) { // State-driven particle spawning float spawnRatePerSec = 0.0f; + int particlesPerSpawn = 1; switch (m_state) { case SyncState::LeftReady: case SyncState::RightReady: - spawnRatePerSec = 3.0f; // slow/occasional + spawnRatePerSec = 24.0f; // steady break; case SyncState::Synced: - spawnRatePerSec = 14.0f; // continuous + spawnRatePerSec = 78.0f; // very heavy stream + particlesPerSpawn = 2; break; default: - spawnRatePerSec = 0.0f; + spawnRatePerSec = 18.0f; // always-on sparkle stream break; } @@ -72,17 +110,25 @@ void SyncLineRenderer::Update(float deltaTime) { m_spawnAcc += deltaTime * spawnRatePerSec; while (m_spawnAcc >= 1.0f) { m_spawnAcc -= 1.0f; - SpawnParticle(); + for (int i = 0; i < particlesPerSpawn; ++i) { + SpawnParticle(); + } } } // Update particles for (auto& p : m_particles) { - p.y -= p.speed * deltaTime; - p.alpha -= 120.0f * deltaTime; + 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) { - return p.y < m_rect.y || p.alpha <= 0.0f; + // 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) { @@ -116,57 +162,197 @@ void SyncLineRenderer::Render(SDL_Renderer* 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); - // 1) Pulse wave (Synced only) + 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) { - float wave = std::fmod(m_pulseTime * 2.0f, 1.0f); - float width = 4.0f + wave * 12.0f; - Uint8 alpha = static_cast(120.0f * (1.0f - wave)); + 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{ - m_rect.x - width * 0.5f, + centerX - (m_rect.w + width) * 0.5f, m_rect.y, m_rect.w + width, m_rect.h }; - SDL_SetRenderDrawColor(renderer, 100, 255, 120, alpha); + SDL_SetRenderDrawColor(renderer, 140, 255, 220, alpha); SDL_RenderFillRect(renderer, &waveRect); } - SDL_Color color = GetBaseColor(); + // 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) { - float pulse = 0.5f + 0.5f * std::sinf(m_time * 6.0f); - color.a = static_cast(180.0f + pulse * 75.0f); + 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); } - // 2) Base SYNC line - SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); - SDL_RenderFillRect(renderer, &m_rect); + 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); - // 3) Energy particles + // 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) { - SDL_FRect dot{ - m_rect.x - 3.0f, - p.y, - m_rect.w + 6.0f, - 3.0f - }; - Uint8 a = static_cast(std::clamp(p.alpha, 0.0f, 255.0f)); - SDL_SetRenderDrawColor(renderer, 120, 255, 180, a); - SDL_RenderFillRect(renderer, &dot); + + // 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); + } } - // 4) Flash/glow overlay + // 7) Flash/glow overlay (adds “clear burst” punch) if (m_state == SyncState::ClearFlash) { - SDL_FRect glow = m_rect; - glow.x -= 3; - glow.w += 6; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); - SDL_SetRenderDrawColor(renderer, 255, 255, 255, 180); + 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 index f8b1d25..2a256fb 100644 --- a/src/graphics/renderers/SyncLineRenderer.h +++ b/src/graphics/renderers/SyncLineRenderer.h @@ -24,9 +24,14 @@ public: private: struct SyncParticle { + float x; float y; - float speed; + float vx; + float vy; + float w; + float h; float alpha; + SDL_Color color; }; SDL_FRect m_rect{}; @@ -40,7 +45,7 @@ private: std::vector m_particles; static constexpr float FLASH_DURATION = 0.15f; - static constexpr size_t MAX_PARTICLES = 64; + static constexpr size_t MAX_PARTICLES = 240; void SpawnParticle(); void SpawnBurst(int count); From fb036dede58cf52d28628135774897d24d98b8ab Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Mon, 22 Dec 2025 18:49:54 +0100 Subject: [PATCH 23/23] removed --- Spacetris-COOPERATE Mode.md | 293 ------------------------------------ supabe_integrate.md | 213 -------------------------- 2 files changed, 506 deletions(-) delete mode 100644 Spacetris-COOPERATE Mode.md delete mode 100644 supabe_integrate.md diff --git a/Spacetris-COOPERATE Mode.md b/Spacetris-COOPERATE Mode.md deleted file mode 100644 index ab625fb..0000000 --- a/Spacetris-COOPERATE Mode.md +++ /dev/null @@ -1,293 +0,0 @@ -# Spacetris — COOPERATE Mode -## Middle SYNC Line Effect (SDL3) -### VS Code Copilot AI Agent Integration Guide - -> Goal: Implement a **visual SYNC divider line** in COOPERATE mode that communicates cooperation state between two players. -> This effect must be lightweight, performant, and decoupled from gameplay logic. - ---- - -## 1. Concept Overview - -The **SYNC Line** is the vertical divider between the two halves of the COOPERATE grid. - -It must: -- Always be visible -- React when one side is ready -- Pulse when both sides are ready -- Flash on line clear -- Provide instant, text-free feedback - -No shaders. No textures. SDL3 only. - ---- - -## 2. Visual States - -Define these states: - -```cpp -enum class SyncState { - Idle, // no side ready - LeftReady, // left half complete - RightReady, // right half complete - Synced, // both halves complete - ClearFlash // row cleared -}; -```` - ---- - -## 3. Color Language - -| State | Color | Meaning | -| ------------------ | --------------- | ------------------- | -| Idle | Blue | Neutral | -| Left / Right Ready | Yellow | Waiting for partner | -| Synced | Green (pulsing) | Perfect cooperation | -| ClearFlash | White | Successful clear | - ---- - -## 4. Geometry - -The SYNC line is a simple rectangle: - -```cpp -SDL_FRect syncLine; -syncLine.x = gridCenterX - 2; -syncLine.y = gridTopY; -syncLine.w = 4; -syncLine.h = gridHeight; -``` - ---- - -## 5. Rendering Rules - -* Always render every frame -* Use alpha blending -* Pulse alpha when synced -* Flash briefly on clear - ---- - -## 6. SyncLineRenderer Class (Required) - -### Header - -```cpp -#pragma once -#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: - SDL_FRect m_rect; - SyncState m_state; - - float m_flashTimer; - float m_time; - - static constexpr float FLASH_DURATION = 0.15f; - - SDL_Color GetBaseColor() const; -}; -``` - ---- - -### Implementation - -```cpp -#include "SyncLineRenderer.h" -#include - -SyncLineRenderer::SyncLineRenderer() - : m_state(SyncState::Idle), - m_flashTimer(0.0f), - m_time(0.0f) {} - -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; -} - -void SyncLineRenderer::Update(float deltaTime) { - m_time += deltaTime; - - 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 {255, 220, 100, 200}; // yellow - - case SyncState::Synced: - return {100, 255, 120, 220}; // green - - case SyncState::ClearFlash: - return {255, 255, 255, 255}; // white - - default: - return {80, 180, 255, 180}; // idle blue - } -} - -void SyncLineRenderer::Render(SDL_Renderer* renderer) { - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); - - SDL_Color color = GetBaseColor(); - - if (m_state == SyncState::Synced) { - float pulse = 0.5f + 0.5f * std::sinf(m_time * 6.0f); - color.a = static_cast(160 + pulse * 80); - } - - SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); - SDL_RenderFillRect(renderer, &m_rect); - - if (m_state == SyncState::ClearFlash) { - SDL_FRect glow = m_rect; - glow.x -= 3; - glow.w += 6; - - SDL_SetRenderDrawColor(renderer, 255, 255, 255, 180); - SDL_RenderFillRect(renderer, &glow); - } -} -``` - ---- - -## 7. Integration Flow - -### Initialization - -```cpp -SyncLineRenderer syncLine; -syncLine.SetRect({ - gridCenterX - 2.0f, - gridTopY, - 4.0f, - gridHeight -}); -``` - ---- - -### Update Loop - -```cpp -syncLine.Update(deltaTime); -``` - ---- - -### Game Logic → Sync State Mapping - -```cpp -if (leftRowReady && rightRowReady) - syncLine.SetState(SyncState::Synced); -else if (leftRowReady) - syncLine.SetState(SyncState::LeftReady); -else if (rightRowReady) - syncLine.SetState(SyncState::RightReady); -else - syncLine.SetState(SyncState::Idle); -``` - ---- - -### On Line Clear Event - -```cpp -syncLine.TriggerClearFlash(); -``` - ---- - -### Render Loop - -```cpp -syncLine.Render(renderer); -``` - ---- - -## 8. Performance Requirements - -* ≤ 2 draw calls per frame -* No textures -* No dynamic allocations -* No blocking logic -* Suitable for 60–240 FPS - ---- - -## 9. UX Rationale - -* Color communicates state without text -* Pulse indicates readiness -* Flash provides satisfaction on success -* Reinforces cooperation visually - ---- - -## 10. Acceptance Criteria - -* Divider is always visible -* State changes are instantly readable -* Flash triggers exactly on cooperative line clear -* No performance impact -* Clean separation from gameplay logic - ---- - -## 11. Optional Future Enhancements (Not Required) - -* Vertical energy particles -* Sound hooks on SYNC / CLEAR -* Combo-based intensity -* Color-blind palette -* Gradient or glow variants - ---- - -## Summary for Copilot - -Implement a `SyncLineRenderer` class in SDL3 to render the cooperative divider line. The line reflects cooperation state through color, pulse, and flash effects. The renderer must be lightweight, stateless with gameplay, and fully driven by external state updates. diff --git a/supabe_integrate.md b/supabe_integrate.md deleted file mode 100644 index 3540c98..0000000 --- a/supabe_integrate.md +++ /dev/null @@ -1,213 +0,0 @@ -# Spacetris — Supabase Highscore Integration -## VS Code Copilot AI Agent Prompt - -You are integrating Supabase highscores into a native C++ SDL3 game called **Spacetris**. - -This is a REST-only integration using Supabase PostgREST. -Do NOT use any Supabase JS SDKs. - ---- - -## 1. Goal - -Implement a highscore backend using Supabase for these game modes: -- classic -- challenge -- cooperate -- versus - -Highscores must be: -- Submitted asynchronously on game over -- Fetched asynchronously for leaderboard screens -- Non-blocking (never stall render loop) -- Offline-safe (fail silently) - ---- - -## 2. Supabase Configuration - -The following constants are provided at build time: - -```cpp -const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co"; -const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA"; -```` - -All requests go to: - -``` -{SUPABASE_URL}/rest/v1/highscores -``` - ---- - -## 3. Database Schema (Already Exists) - -The Supabase table `highscores` has the following fields: - -* score (integer) -* lines (integer) -* level (integer) -* time_sec (integer) -* name (string) -* game_type ("classic", "versus", "cooperate", "challenge") -* timestamp (integer, UNIX epoch seconds) - ---- - -## 4. Data Model in C++ - -Create a struct matching the database schema: - -```cpp -struct HighscoreEntry { - int score; - int lines; - int level; - int timeSec; - std::string name; - std::string gameType; - int timestamp; -}; -``` - ---- - -## 5. HTTP Layer Requirements - -* Use **libcurl** -* Use **JSON** (nlohmann::json or equivalent) -* All network calls must run in a worker thread -* Never block the SDL main loop - -Required HTTP headers: - -``` -apikey: SUPABASE_ANON_KEY -Authorization: Bearer SUPABASE_ANON_KEY -Content-Type: application/json -``` - ---- - -## 6. Submit Highscore (POST) - -Implement: - -```cpp -void SubmitHighscoreAsync(const HighscoreEntry& entry); -``` - -Behavior: - -* Convert entry to JSON -* POST to `/rest/v1/highscores` -* On failure: - - * Log error - * Do NOT crash - * Optionally store JSON locally for retry - -Example JSON payload: - -```json -{ - "score": 123456, - "lines": 240, - "level": 37, - "time_sec": 1820, - "name": "P1 & P2", - "game_type": "cooperate", - "timestamp": 1710000000 -} -``` - ---- - -## 7. Fetch Leaderboard (GET) - -Implement: - -```cpp -std::vector FetchHighscores( - const std::string& gameType, - int limit -); -``` - -REST query examples: - -Classic: - -``` -?game_type=eq.classic&order=score.desc&limit=20 -``` - -Challenge: - -``` -?game_type=eq.challenge&order=level.desc,time_sec.asc&limit=20 -``` - -Cooperate: - -``` -?game_type=eq.cooperate&order=score.desc&limit=20 -``` - ---- - -## 8. Threading Model - -* Use `std::thread` or a simple job queue -* Network calls must not run on the render thread -* Use mutex or lock-free queue to pass results back to UI - ---- - -## 9. Error Handling Rules - -* If Supabase is unreachable: - - * Game continues normally - * Leaderboard screen shows "Offline" -* Never block gameplay -* Never show raw network errors to player - ---- - -## 10. Security Constraints - -* API key is public (acceptable for highscores) -* Obfuscate key in binary if possible -* Do NOT trust client-side data (future server validation planned) - ---- - -## 11. File Structure Suggestion - -``` -/network - supabase_client.h - supabase_client.cpp - -/highscores - highscore_submit.cpp - highscore_fetch.cpp -``` - ---- - -## 12. Acceptance Criteria - -* Highscores are submitted after game over -* Leaderboards load without blocking gameplay -* Works for all four game types -* Offline mode does not crash or freeze -* Code is clean, modular, and SDL3-safe - ---- - -## 13. Summary for the Agent - -Integrate Supabase highscores into Spacetris using REST calls from C++ with libcurl. Use async submission and fetching. Support classic, challenge, cooperate, and versus modes. Ensure non-blocking behavior and graceful offline handling.