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