diff --git a/CMakeLists.txt b/CMakeLists.txt index 2882987..81ff6dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,7 @@ set(TETRIS_SOURCES src/app/TetrisApp.cpp src/gameplay/core/Game.cpp src/gameplay/coop/CoopGame.cpp + src/gameplay/coop/CoopAIController.cpp src/core/GravityManager.cpp src/core/state/StateManager.cpp # New core architecture classes diff --git a/assets/images/cooperate_info.png b/assets/images/cooperate_info.png new file mode 100644 index 0000000..4b586aa Binary files /dev/null and b/assets/images/cooperate_info.png differ diff --git a/settings.ini b/settings.ini index 86008f0..7e2630a 100644 --- a/settings.ini +++ b/settings.ini @@ -5,7 +5,7 @@ Fullscreen=1 [Audio] -Music=0 +Music=1 Sound=1 [Gameplay] @@ -14,7 +14,7 @@ SmoothScroll=1 UpRotateClockwise=0 [Player] -Name=GREGOR +Name=P2 [Debug] Enabled=1 diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 594f979..0d9d0fc 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -38,6 +38,7 @@ #include "gameplay/core/Game.h" #include "gameplay/coop/CoopGame.h" +#include "gameplay/coop/CoopAIController.h" #include "gameplay/effects/LineEffect.h" #include "graphics/effects/SpaceWarp.h" @@ -239,6 +240,11 @@ struct TetrisApp::Impl { bool suppressLineVoiceForLevelUp = false; bool skipNextLevelUpJingle = false; + // COOPERATE option: when true, right player is AI-controlled. + bool coopVsAI = false; + + CoopAIController coopAI; + AppState state = AppState::Loading; double loadingProgress = 0.0; Uint64 loadStart = 0; @@ -567,6 +573,7 @@ int TetrisApp::Impl::init() ctx.mainScreenW = mainScreenW; ctx.mainScreenH = mainScreenH; ctx.musicEnabled = &musicEnabled; + ctx.coopVsAI = &coopVsAI; ctx.startLevelSelection = &startLevelSelection; ctx.hoveredButton = &hoveredButton; ctx.showSettingsPopup = &showSettingsPopup; @@ -628,10 +635,17 @@ int TetrisApp::Impl::init() return; } if (state != AppState::Menu) { + if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) { + coopAI.reset(); + } state = AppState::Playing; ctx.stateManager->setState(state); return; } + + if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) { + coopAI.reset(); + } beginStateFade(AppState::Playing, true); }; ctx.startPlayTransition = startMenuPlayTransition; @@ -894,27 +908,45 @@ void TetrisApp::Impl::runLoop() if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { if (isNewHighScore) { 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 (coopVsAI) { + // One-name entry flow (CPU is LEFT, human enters RIGHT name) + if (e.key.scancode == SDL_SCANCODE_BACKSPACE) { + if (!player2Name.empty()) player2Name.pop_back(); + } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { if (player2Name.empty()) player2Name = "P2"; - // Submit combined name - std::string combined = playerName + " & " + player2Name; + std::string combined = std::string("CPU") + " & " + player2Name; int leftScore = coopGame->score(CoopGame::PlayerSide::Left); int rightScore = coopGame->score(CoopGame::PlayerSide::Right); int combinedScore = leftScore + rightScore; ensureScoresLoaded(); scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); - Settings::instance().setPlayerName(playerName); + Settings::instance().setPlayerName(player2Name); isNewHighScore = false; SDL_StopTextInput(window); } + } else { + // Two-name entry flow + if (e.key.scancode == SDL_SCANCODE_BACKSPACE) { + if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back(); + else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back(); + } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { + if (highScoreEntryIndex == 0) { + if (playerName.empty()) playerName = "P1"; + highScoreEntryIndex = 1; // move to second name + } else { + if (player2Name.empty()) player2Name = "P2"; + // Submit combined name + std::string combined = playerName + " & " + player2Name; + int leftScore = coopGame->score(CoopGame::PlayerSide::Left); + int rightScore = coopGame->score(CoopGame::PlayerSide::Right); + int combinedScore = leftScore + rightScore; + ensureScoresLoaded(); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); + Settings::instance().setPlayerName(playerName); + isNewHighScore = false; + SDL_StopTextInput(window); + } + } } } else { if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) { @@ -972,11 +1004,9 @@ void TetrisApp::Impl::runLoop() startMenuPlayTransition(); break; case ui::BottomMenuItem::Cooperate: - if (game) { - game->setMode(GameMode::Cooperate); - game->reset(startLevelSelection); + if (menuState) { + menuState->showCoopSetupPanel(true); } - startMenuPlayTransition(); break; case ui::BottomMenuItem::Challenge: if (game) { @@ -1288,13 +1318,44 @@ void TetrisApp::Impl::runLoop() 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); + // Define canonical key mappings for left and right players + const SDL_Scancode leftLeftKey = SDL_SCANCODE_A; + const SDL_Scancode leftRightKey = SDL_SCANCODE_D; + const SDL_Scancode leftDownKey = SDL_SCANCODE_S; - p1LeftHeld = ks[SDL_SCANCODE_A]; - p1RightHeld = ks[SDL_SCANCODE_D]; - p2LeftHeld = ks[SDL_SCANCODE_LEFT]; - p2RightHeld = ks[SDL_SCANCODE_RIGHT]; + const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT; + const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT; + const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN; + + if (!coopVsAI) { + // Standard two-player: left uses WASD, right uses arrow keys + handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey); + handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); + + p1LeftHeld = ks[leftLeftKey]; + p1RightHeld = ks[leftRightKey]; + p2LeftHeld = ks[rightLeftKey]; + p2RightHeld = ks[rightRightKey]; + } else { + // Coop vs CPU: AI controls LEFT, human controls RIGHT (arrow keys). + // Handle continuous input for the human on the right side. + handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); + + // Mirror the human soft-drop to the AI-controlled left board so both fall together. + const bool pRightSoftDrop = ks[rightDownKey]; + coopGame->setSoftDropping(CoopGame::PlayerSide::Left, pRightSoftDrop); + + // Reset left continuous timers/held flags (AI handles movement) + p1MoveTimerMs = 0.0; + p1LeftHeld = false; + p1RightHeld = false; + + // Update AI for the left side + coopAI.update(*coopGame, CoopGame::PlayerSide::Left, frameMs); + // Update human-held flags for right-side controls so DAS/ARR state is tracked + p2LeftHeld = ks[rightLeftKey]; + p2RightHeld = ks[rightRightKey]; + } coopGame->tickGravity(frameMs); coopGame->updateVisualEffects(frameMs); @@ -1307,14 +1368,22 @@ void TetrisApp::Impl::runLoop() int combinedScore = leftScore + rightScore; if (combinedScore > 0) { isNewHighScore = true; - playerName.clear(); - player2Name.clear(); - highScoreEntryIndex = 0; + if (coopVsAI) { + // AI is left, prompt human (right) for name + playerName = "CPU"; + player2Name.clear(); + highScoreEntryIndex = 1; // enter P2 (human) + } else { + playerName.clear(); + player2Name.clear(); + highScoreEntryIndex = 0; + } SDL_StartTextInput(window); } else { isNewHighScore = false; ensureScoresLoaded(); - scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate"); + // When AI is present, label should indicate CPU left and human right + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), coopVsAI ? "CPU & P2" : "P1 & P2", "cooperate"); } state = AppState::GameOver; stateMgr->setState(state); diff --git a/src/gameplay/coop/CoopAIController.cpp b/src/gameplay/coop/CoopAIController.cpp new file mode 100644 index 0000000..956fe9a --- /dev/null +++ b/src/gameplay/coop/CoopAIController.cpp @@ -0,0 +1,317 @@ +#include "CoopAIController.h" + +#include "CoopGame.h" + +#include +#include +#include +#include + +namespace { + +static bool canPlacePieceForSide(const std::array& board, + const CoopGame::Piece& p, + CoopGame::PlayerSide side) { + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!CoopGame::cellFilled(p, cx, cy)) { + continue; + } + + const int bx = p.x + cx; + const int by = p.y + cy; + + // Keep the AI strictly in the correct half. + if (side == CoopGame::PlayerSide::Right) { + if (bx < 10 || bx >= CoopGame::COLS) { + return false; + } + } else { + if (bx < 0 || bx >= 10) { + return false; + } + } + + // Above the visible board is allowed. + if (by < 0) { + continue; + } + + if (by >= CoopGame::ROWS) { + return false; + } + + if (board[by * CoopGame::COLS + bx].occupied) { + return false; + } + } + } + return true; +} + +static int dropYFor(const std::array& board, + CoopGame::Piece p, + CoopGame::PlayerSide side) { + // Assumes p is currently placeable. + while (true) { + CoopGame::Piece next = p; + next.y += 1; + if (!canPlacePieceForSide(board, next, side)) { + return p.y; + } + p = next; + if (p.y > CoopGame::ROWS) { + return p.y; + } + } +} + +static void applyPiece(std::array& occ, + const CoopGame::Piece& p) { + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!CoopGame::cellFilled(p, cx, cy)) { + continue; + } + const int bx = p.x + cx; + const int by = p.y + cy; + if (by < 0 || by >= CoopGame::ROWS || bx < 0 || bx >= CoopGame::COLS) { + continue; + } + occ[by * CoopGame::COLS + bx] = 1; + } + } +} + +struct Eval { + double score = -std::numeric_limits::infinity(); + int rot = 0; + int x = 10; +}; + +static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) { + const auto& board = game.boardRef(); + + std::array occ{}; + for (int i = 0; i < CoopGame::COLS * CoopGame::ROWS; ++i) { + occ[i] = board[i].occupied ? 1 : 0; + } + + const CoopGame::Piece cur = game.current(side); + + Eval best{}; + + // Iterate rotations and x positions. IMPORTANT: allow x to go slightly out of bounds + // because our pieces are represented in a 4x4 mask and many rotations have leading + // empty columns. For example, placing a vertical I/J/L into column 0 often requires + // p.x == -1 or p.x == -2 so the filled cells land at bx==0. + // canPlacePieceForSide() enforces the actual half-board bounds. + for (int rot = 0; rot < 4; ++rot) { + int xmin = (side == CoopGame::PlayerSide::Right) ? 6 : -3; + int xmax = (side == CoopGame::PlayerSide::Right) ? 22 : 13; + for (int x = xmin; x <= xmax; ++x) { + CoopGame::Piece p = cur; + p.rot = rot; + p.x = x; + + // If this rotation/x is illegal at the current y, try near the top spawn band. + if (!canPlacePieceForSide(board, p, side)) { + p.y = -2; + if (!canPlacePieceForSide(board, p, side)) { + continue; + } + } + + p.y = dropYFor(board, p, side); + + auto occ2 = occ; + applyPiece(occ2, p); + + // Count completed full rows (all 20 cols) after placement. + int fullRows = 0; + for (int y = 0; y < CoopGame::ROWS; ++y) { + bool full = true; + for (int cx = 0; cx < CoopGame::COLS; ++cx) { + if (!occ2[y * CoopGame::COLS + cx]) { + full = false; + break; + } + } + if (full) { + ++fullRows; + } + } + + // Right-half column heights + holes + bumpiness. + std::array heights{}; + int aggregateHeight = 0; + int holes = 0; + + for (int c = 0; c < 10; ++c) { + const int bx = (side == CoopGame::PlayerSide::Right) ? (10 + c) : c; + int h = 0; + bool found = false; + for (int y = 0; y < CoopGame::ROWS; ++y) { + if (occ2[y * CoopGame::COLS + bx]) { + h = CoopGame::ROWS - y; + found = true; + // Count holes below the first filled cell. + for (int yy = y + 1; yy < CoopGame::ROWS; ++yy) { + if (!occ2[yy * CoopGame::COLS + bx]) { + ++holes; + } + } + break; + } + } + heights[c] = found ? h : 0; + aggregateHeight += heights[c]; + } + + int bump = 0; + for (int i = 0; i < 9; ++i) { + bump += std::abs(heights[i] - heights[i + 1]); + } + + // Reward sync potential: rows where the right half is full (10..19). + int sideHalfFullRows = 0; + for (int y = 0; y < CoopGame::ROWS; ++y) { + bool full = true; + int start = (side == CoopGame::PlayerSide::Right) ? 10 : 0; + int end = (side == CoopGame::PlayerSide::Right) ? 20 : 10; + for (int bx = start; bx < end; ++bx) { + if (!occ2[y * CoopGame::COLS + bx]) { + full = false; + break; + } + } + if (full) { + ++sideHalfFullRows; + } + } + + // Simple heuristic: + // - Strongly prefer completed full rows + // - Prefer making the right half complete (helps cooperative clears) + // - Penalize holes and excessive height/bumpiness + double s = 0.0; + // Strongly prefer full-line clears across the whole board (rare but best). + s += static_cast(fullRows) * 12000.0; + // Heavily prefer completing the player's half — make this a primary objective. + s += static_cast(sideHalfFullRows) * 6000.0; + // Penalize holes and height less aggressively so completing half-rows is prioritized. + s -= static_cast(holes) * 180.0; + s -= static_cast(aggregateHeight) * 4.0; + s -= static_cast(bump) * 10.0; + + // Reduce center bias so edge placements to complete rows are not punished. + double centerTarget = (side == CoopGame::PlayerSide::Right) ? 15.0 : 4.5; + const double centerBias = -std::abs((x + 1.5) - centerTarget) * 1.0; + s += centerBias; + + if (s > best.score) { + best.score = s; + best.rot = rot; + best.x = x; + } + } + } + + return best; +} + +} // namespace + +void CoopAIController::reset() { + m_lastPieceSeq = 0; + m_hasPlan = false; + m_targetRot = 0; + m_targetX = 10; + m_moveTimerMs = 0.0; + m_moveDir = 0; + m_rotateTimerMs = 0.0; +} + +void CoopAIController::computePlan(const CoopGame& game, CoopGame::PlayerSide side) { + const Eval best = evaluateBestPlacementForSide(game, side); + m_targetRot = best.rot; + m_targetX = best.x; + m_hasPlan = true; + m_moveTimerMs = 0.0; + m_moveDir = 0; + m_rotateTimerMs = 0.0; +} + +void CoopAIController::update(CoopGame& game, CoopGame::PlayerSide side, double frameMs) { + const uint64_t seq = game.currentPieceSequence(side); + if (seq != m_lastPieceSeq) { + m_lastPieceSeq = seq; + m_hasPlan = false; + m_moveTimerMs = 0.0; + m_moveDir = 0; + m_rotateTimerMs = 0.0; + } + + if (!m_hasPlan) { + computePlan(game, side); + } + + const CoopGame::Piece cur = game.current(side); + + // Clamp negative deltas (defensive; callers should pass >= 0). + const double dt = std::max(0.0, frameMs); + + // Update timers. + if (m_moveTimerMs > 0.0) { + m_moveTimerMs -= dt; + if (m_moveTimerMs < 0.0) m_moveTimerMs = 0.0; + } + if (m_rotateTimerMs > 0.0) { + m_rotateTimerMs -= dt; + if (m_rotateTimerMs < 0.0) m_rotateTimerMs = 0.0; + } + + // Rotate toward target first. + const int curRot = ((cur.rot % 4) + 4) % 4; + const int tgtRot = ((m_targetRot % 4) + 4) % 4; + int diff = (tgtRot - curRot + 4) % 4; + if (diff != 0) { + // Human-ish rotation rate limiting. + if (m_rotateTimerMs <= 0.0) { + const int dir = (diff == 3) ? -1 : 1; + game.rotate(side, dir); + m_rotateTimerMs = m_rotateIntervalMs; + } + // While rotating, do not also slide horizontally in the same frame. + m_moveDir = 0; + m_moveTimerMs = 0.0; + return; + } + + // Move horizontally toward target. + int desiredDir = 0; + if (cur.x < m_targetX) desiredDir = +1; + else if (cur.x > m_targetX) desiredDir = -1; + + if (desiredDir == 0) { + // Aligned: do nothing. Gravity controls fall speed (no AI hard drops). + m_moveDir = 0; + m_moveTimerMs = 0.0; + return; + } + + // DAS/ARR-style horizontal movement pacing. + if (m_moveDir != desiredDir) { + // New direction / initial press: move immediately, then wait DAS. + game.move(side, desiredDir); + m_moveDir = desiredDir; + m_moveTimerMs = m_dasMs; + return; + } + + // Holding direction: repeat every ARR once DAS has elapsed. + if (m_moveTimerMs <= 0.0) { + game.move(side, desiredDir); + m_moveTimerMs = m_arrMs; + } +} diff --git a/src/gameplay/coop/CoopAIController.h b/src/gameplay/coop/CoopAIController.h new file mode 100644 index 0000000..2379e08 --- /dev/null +++ b/src/gameplay/coop/CoopAIController.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include "CoopGame.h" + +// Minimal, lightweight AI driver for a CoopGame player side (left or right). +// It chooses a target rotation/x placement using a simple board heuristic, +// then steers the active piece toward that target at a human-like input rate. +class CoopAIController { +public: + CoopAIController() = default; + + void reset(); + + // frameMs is the frame delta in milliseconds (same unit used across the gameplay loop). + void update(CoopGame& game, CoopGame::PlayerSide side, double frameMs); + +private: + uint64_t m_lastPieceSeq = 0; + bool m_hasPlan = false; + + int m_targetRot = 0; + int m_targetX = 10; + + // Input pacing (ms). These intentionally mirror the defaults used for human input. + double m_dasMs = 170.0; + double m_arrMs = 40.0; + double m_rotateIntervalMs = 110.0; + + // Internal timers/state for rate limiting. + double m_moveTimerMs = 0.0; + int m_moveDir = 0; // -1, 0, +1 + double m_rotateTimerMs = 0.0; + + void computePlan(const CoopGame& game, CoopGame::PlayerSide side); +}; diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index aad5d23..e6676bc 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -307,9 +307,8 @@ void CoopGame::spawn(PlayerState& ps) { pieceSequence++; if (collides(ps, ps.cur)) { ps.toppedOut = true; - if (left.toppedOut && right.toppedOut) { - gameOver = true; - } + // Cooperative mode: game ends when any player tops out. + gameOver = true; } } diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index fc63209..bdcd0fa 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -9,6 +9,8 @@ #include "../audio/Audio.h" #include "../audio/SoundEffect.h" #include +#include +#include #include #include #include @@ -110,6 +112,54 @@ static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP MenuState::MenuState(StateContext& ctx) : State(ctx) {} +void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) { + if (show) { + if (!coopSetupVisible && !coopSetupAnimating) { + // Avoid overlapping panels + if (aboutPanelVisible && !aboutPanelAnimating) { + aboutPanelAnimating = true; + aboutDirection = -1; + } + if (helpPanelVisible && !helpPanelAnimating) { + helpPanelAnimating = true; + helpDirection = -1; + } + if (optionsVisible && !optionsAnimating) { + optionsAnimating = true; + optionsDirection = -1; + } + if (levelPanelVisible && !levelPanelAnimating) { + levelPanelAnimating = true; + levelDirection = -1; + } + if (exitPanelVisible && !exitPanelAnimating) { + exitPanelAnimating = true; + exitDirection = -1; + if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; + } + + coopSetupAnimating = true; + coopSetupDirection = 1; + coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0; + coopSetupRectsValid = false; + selectedButton = static_cast(ui::BottomMenuItem::Cooperate); + // Ensure the transition value is non-zero so render code can show + // the inline choice buttons immediately on the same frame. + if (coopSetupTransition <= 0.0) coopSetupTransition = 0.001; + } + } else { + if (coopSetupVisible && !coopSetupAnimating) { + coopSetupAnimating = true; + coopSetupDirection = -1; + coopSetupRectsValid = false; + // Resume menu music only when requested (ESC should pass resumeMusic=false) + if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) { + Audio::instance().playMenuMusic(); + } + } + } +} + void MenuState::showHelpPanel(bool show) { if (show) { if (!helpPanelVisible && !helpPanelAnimating) { @@ -204,10 +254,11 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, }; int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; - ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel); + const bool coopVsAI = ctx.coopVsAI ? *ctx.coopVsAI : false; + ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel, coopVsAI); const int hovered = (ctx.hoveredButton ? *ctx.hoveredButton : -1); - const double baseAlpha = 1.0; + const double baseAlpha = 1.0; // Base alpha for button rendering // Pulse is encoded as a signed delta so PLAY can dim/brighten while focused. const double pulseDelta = (buttonPulseAlpha - 1.0); const double flashDelta = buttonFlash * buttonFlashAmount; @@ -225,9 +276,113 @@ void MenuState::onExit() { if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; } if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; } if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; } + if (coopInfoTexture) { SDL_DestroyTexture(coopInfoTexture); coopInfoTexture = nullptr; } } void MenuState::handleEvent(const SDL_Event& e) { + // Coop setup panel navigation (modal within the menu) + // Handle this FIRST and consume key events so the main menu navigation doesn't interfere. + // Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat. + if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) { + switch (e.key.scancode) { + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_A: + coopSetupSelected = 0; + buttonFlash = 1.0; + return; + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_D: + coopSetupSelected = 1; + buttonFlash = 1.0; + return; + // Do NOT allow up/down to change anything + case SDL_SCANCODE_UP: + case SDL_SCANCODE_DOWN: + return; + case SDL_SCANCODE_ESCAPE: + showCoopSetupPanel(false, false); + return; + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + { + const bool useAI = (coopSetupSelected == 1); + if (ctx.coopVsAI) { + *ctx.coopVsAI = useAI; + } + 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); + } + + // Close the panel without restarting menu music; gameplay will take over. + showCoopSetupPanel(false, false); + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager); + + if (ctx.startPlayTransition) { + ctx.startPlayTransition(); + } else if (ctx.stateManager) { + ctx.stateManager->setState(AppState::Playing); + } + return; + } + default: + // Allow all other keys to be pressed, but don't let them affect the main menu while coop is open. + return; + } + } + + // Mouse input for COOP setup panel or inline coop buttons + if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) { + if (coopSetupRectsValid) { + // While the coop submenu is active (animating or visible) we disallow + // mouse interaction — only keyboard LEFT/RIGHT/ESC is permitted. + if (coopSetupAnimating || coopSetupVisible) { + return; + } + const float mx = static_cast(e.button.x); + const float my = static_cast(e.button.y); + if (mx >= lastLogicalVP.x && my >= lastLogicalVP.y && mx <= (lastLogicalVP.x + lastLogicalVP.w) && my <= (lastLogicalVP.y + lastLogicalVP.h)) { + const float lx = (mx - lastLogicalVP.x) / std::max(0.0001f, lastLogicalScale); + const float ly = (my - lastLogicalVP.y) / std::max(0.0001f, lastLogicalScale); + + auto hit = [&](const SDL_FRect& r) { + return lx >= r.x && lx <= (r.x + r.w) && ly >= r.y && ly <= (r.y + r.h); + }; + + int chosen = -1; + if (hit(coopSetupBtnRects[0])) chosen = 0; + else if (hit(coopSetupBtnRects[1])) chosen = 1; + + if (chosen != -1) { + coopSetupSelected = chosen; + const bool useAI = (coopSetupSelected == 1); + if (ctx.coopVsAI) { + *ctx.coopVsAI = useAI; + } + 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); + } + showCoopSetupPanel(false); + if (ctx.startPlayTransition) { + ctx.startPlayTransition(); + } else if (ctx.stateManager) { + ctx.stateManager->setState(AppState::Playing); + } + return; + } + } + } + } + // Keyboard navigation for menu buttons if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { // When the player uses the keyboard, don't let an old mouse hover keep focus on a button. @@ -290,8 +445,21 @@ void MenuState::handleEvent(const SDL_Event& e) { } return; case SDL_SCANCODE_ESCAPE: - // Close HUD - exitPanelAnimating = true; exitDirection = -1; + showCoopSetupPanel(false, true); + // Cannot print std::function as a pointer; print presence (1/0) instead + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop ENTER pressed, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager); + if (ctx.startPlayTransition) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: calling startPlayTransition"); + ctx.startPlayTransition(); + } else { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: startPlayTransition is null"); + } + if (ctx.stateManager) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: setting AppState::Playing on stateManager"); + ctx.stateManager->setState(AppState::Playing); + } else { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: stateManager is null"); + } if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; return; case SDL_SCANCODE_PAGEDOWN: @@ -457,6 +625,49 @@ void MenuState::handleEvent(const SDL_Event& e) { return; } + // Coop setup panel navigation (modal within the menu) + if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0) { + switch (e.key.scancode) { + case SDL_SCANCODE_LEFT: + coopSetupSelected = 0; + buttonFlash = 1.0; + return; + case SDL_SCANCODE_RIGHT: + coopSetupSelected = 1; + buttonFlash = 1.0; + return; + // Explicitly consume Up/Down so main menu navigation doesn't trigger + case SDL_SCANCODE_UP: + case SDL_SCANCODE_DOWN: + return; + case SDL_SCANCODE_ESCAPE: + // Close coop panel without restarting music + showCoopSetupPanel(false, false); + return; + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + { + const bool useAI = (coopSetupSelected == 1); + if (ctx.coopVsAI) { + *ctx.coopVsAI = useAI; + } + 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); + } + showCoopSetupPanel(false, false); + triggerPlay(); + return; + } + default: + break; + } + } + switch (e.key.scancode) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_UP: @@ -489,15 +700,8 @@ 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(); + // Cooperative play: open setup panel (2P vs AI) + showCoopSetupPanel(true); break; case 2: // Start challenge run at level 1 @@ -566,6 +770,10 @@ void MenuState::handleEvent(const SDL_Event& e) { } break; case SDL_SCANCODE_ESCAPE: + if (coopSetupVisible && !coopSetupAnimating) { + showCoopSetupPanel(false, false); + return; + } // If options panel is visible, hide it first. if (optionsVisible && !optionsAnimating) { optionsAnimating = true; @@ -665,6 +873,21 @@ void MenuState::update(double frameMs) { } } + // Advance coop setup panel animation if active + if (coopSetupAnimating) { + double delta = (frameMs / coopSetupTransitionDurationMs) * static_cast(coopSetupDirection); + coopSetupTransition += delta; + if (coopSetupTransition >= 1.0) { + coopSetupTransition = 1.0; + coopSetupVisible = true; + coopSetupAnimating = false; + } else if (coopSetupTransition <= 0.0) { + coopSetupTransition = 0.0; + coopSetupVisible = false; + coopSetupAnimating = false; + } + } + // Animate level selection highlight position toward the selected cell center if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) { // Recompute same grid geometry used in render to find target center @@ -790,6 +1013,8 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi const float moveAmount = 420.0f; // increased so lower score rows slide further up // Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown. + // Exclude coopSetupTransition from the highscores slide so opening the + // COOPERATE setup does not shift the highscores panel upward. float combinedTransition = static_cast(std::max( std::max(std::max(optionsTransition, levelTransition), exitTransition), std::max(helpTransition, aboutTransition) @@ -823,8 +1048,8 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi } } - // Small TOP PLAYER label under the logo - const std::string smallTitle = "TOP PLAYER"; + // Small label under the logo — show "COOPERATE" when coop setup is active + const std::string smallTitle = (coopSetupAnimating || coopSetupVisible) ? "COOPERATE" : "TOP PLAYER"; float titleScale = 0.9f; int tW = 0, tH = 0; useFont->measure(smallTitle, titleScale, tW, tH); @@ -848,7 +1073,9 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi 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) { + // Keep highscores visible while the coop setup is animating; hide them only + // once the coop setup is fully visible so the buttons can appear afterward. + if (useFont && !coopSetupVisible) { 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) @@ -1112,6 +1339,154 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi } } + // Inline COOP choice buttons: when COOPERATE is selected show two large + // choice buttons in the highscores panel area (top of the screen). + // coopSetupRectsValid is cleared each frame and set to true when buttons are drawn + coopSetupRectsValid = false; + // Draw the inline COOP choice buttons as soon as the coop setup starts + // animating or is visible. Highscores are no longer slid upward when + // the setup opens, so the buttons can show immediately. + if (coopSetupAnimating || coopSetupVisible) { + // Recompute panel geometry matching highscores layout above so buttons + // appear centered inside the same visual area. + const float panelW = std::min(920.0f, LOGICAL_W * 0.92f); + const float panelShift = LOGICAL_W * 0.015f; + const float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX - panelShift; + const float panelH = 36.0f + maxDisplay * 36.0f; // same as highscores panel + // Highscores are animated upward by `panelDelta` while opening the coop setup. + // We want the choice buttons to appear *after* that scroll, in the original + // highscores area (not sliding offscreen with the scores). + const float panelBaseY = scoresStartY - 20.0f; + + // Make the choice buttons smaller, add more spacing, and raise them higher + const float btnW2 = std::min(300.0f, panelW * 0.30f); + const float btnH2 = 60.0f; + const float gap = 96.0f; + // Shift the image and buttons to the right for layout balance (reduced) + const float shiftX = 20.0f; // move right by 30px (moved 20px left from previous) + const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f + shiftX; + // Move the buttons up by ~80px to sit closer under the logo + const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f; + + coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 }; + coopSetupBtnRects[1] = SDL_FRect{ bx + btnW2 + gap, by, btnW2, btnH2 }; + coopSetupRectsValid = true; + + SDL_Color bg{ 24, 36, 52, 220 }; + SDL_Color border{ 110, 200, 255, 220 }; + + // Load coop info image once when the coop setup is first shown + if (!coopInfoTexture) { + const std::string resolved = AssetPath::resolveImagePath("assets/images/cooperate_info.png"); + if (!resolved.empty()) { + SDL_Surface* surf = IMG_Load(resolved.c_str()); + if (surf) { + // Save dimensions from surface then create texture + coopInfoTexW = surf->w; + coopInfoTexH = surf->h; + coopInfoTexture = SDL_CreateTextureFromSurface(renderer, surf); + SDL_DestroySurface(surf); + } else { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "MenuState: failed to load %s: %s", resolved.c_str(), SDL_GetError()); + } + } + } + + // If the image loaded, render it centered above the two choice buttons + // Compute fade alpha from the coop transition so it can be used for image, text and buttons + float alphaFactor = static_cast(coopSetupTransition); + if (alphaFactor < 0.0f) alphaFactor = 0.0f; + if (alphaFactor > 1.0f) alphaFactor = 1.0f; + if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) { + float totalW = btnW2 * 2.0f + gap; + // Increase allowed image width by ~15% (was 0.75 of totalW) + const float scaleFactor = 0.75f * 1.25f; // ~0.8625 + float maxImgW = totalW * scaleFactor; + float targetW = std::min(maxImgW, static_cast(coopInfoTexW)); + float scale = targetW / static_cast(coopInfoTexW); + float targetH = static_cast(coopInfoTexH) * scale; + float imgX = bx + (totalW - targetW) * 0.5f; + float imgY = by - targetH - 8.0f; // keep the small gap above buttons + float minY = panelBaseY + 6.0f; + if (imgY < minY) imgY = minY; + SDL_FRect dst{ imgX, imgY, targetW, targetH }; + SDL_SetTextureBlendMode(coopInfoTexture, SDL_BLENDMODE_BLEND); + // Make the coop info image slightly transparent scaled by transition + SDL_SetTextureAlphaMod(coopInfoTexture, static_cast(std::round(200.0f * alphaFactor))); + SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst); + + // Draw cooperative instructions inside the panel area (overlayed on the panel background) + FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; + if (f) { + const float pad = 38.0f; + float textX = panelBaseX + pad; + // Position the text over the lower portion of the image (overlay) + // Move the block upward by ~150px to match UI request + float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f; + + // Bulleted list (measure sample line height first) + const std::vector bullets = { + "The playfield is shared between two players", + "Each player controls one half of the grid", + "A line clears only when both halves are filled", + "Timing and coordination are essential" + }; + float bulletScale = 0.78f; + SDL_Color bulletCol{200,220,230,220}; + bulletCol.a = static_cast(std::round(bulletCol.a * alphaFactor)); + int sampleLW = 0, sampleLH = 0; + f->measure(bullets[0], bulletScale, sampleLW, sampleLH); + + // Header: move it up by one sample row so it sits higher + const std::string header = "* HOW TO PLAY – COOPERATE MODE *"; + float headerScale = 0.95f; + int hW=0, hH=0; f->measure(header, headerScale, hW, hH); + float hx = panelBaseX + (panelW - static_cast(hW)) * 0.5f + 40.0f; // nudge header right by 40px + float headerY = textY - static_cast(sampleLH); + SDL_Color headerCol = SDL_Color{220,240,255,230}; headerCol.a = static_cast(std::round(headerCol.a * alphaFactor)); + f->draw(renderer, hx, headerY, header, headerScale, headerCol); + // Start body text slightly below header + textY = headerY + static_cast(hH) + 8.0f; + + // Shift non-header text to the right by 100px and down by 20px + float bulletX = textX + 200.0f; + textY += 20.0f; + for (const auto &line : bullets) { + std::string withBullet = std::string("• ") + line; + f->draw(renderer, bulletX, textY, withBullet, bulletScale, bulletCol); + int lw=0, lH=0; f->measure(withBullet, bulletScale, lw, lH); + textY += static_cast(lH) + 6.0f; + } + + // GOAL section (aligned with shifted bullets) + textY += 6.0f; + std::string goalTitle = "GOAL:"; + SDL_Color goalTitleCol = SDL_Color{255,215,80,230}; goalTitleCol.a = static_cast(std::round(goalTitleCol.a * alphaFactor)); + f->draw(renderer, bulletX, textY, goalTitle, 0.88f, goalTitleCol); + int gW=0, gH=0; f->measure(goalTitle, 0.88f, gW, gH); + float goalX = bulletX + static_cast(gW) + 10.0f; + std::string goalText = "Clear lines together and achieve the highest TEAM SCORE"; + SDL_Color goalTextCol = SDL_Color{220,240,255,220}; goalTextCol.a = static_cast(std::round(goalTextCol.a * alphaFactor)); + f->draw(renderer, goalX, textY, goalText, 0.86f, goalTextCol); + } + } + + // Delay + eased fade specifically for the two coop buttons so they appear after the image/text. + const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading + float rawBtn = (alphaFactor - btnDelay) / (1.0f - btnDelay); + rawBtn = std::clamp(rawBtn, 0.0f, 1.0f); + // ease-in (squared) for a slower, smoother fade + float buttonFade = rawBtn * rawBtn; + SDL_Color bgA = bg; bgA.a = static_cast(std::round(bgA.a * buttonFade)); + SDL_Color borderA = border; borderA.a = static_cast(std::round(borderA.a * buttonFade)); + UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[0].x + btnW2 * 0.5f, coopSetupBtnRects[0].y + btnH2 * 0.5f, + btnW2, btnH2, "2 PLAYERS", false, coopSetupSelected == 0, bgA, borderA, false, nullptr); + UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[1].x + btnW2 * 0.5f, coopSetupBtnRects[1].y + btnH2 * 0.5f, + btnW2, btnH2, "COMPUTER (AI)", false, coopSetupSelected == 1, bgA, borderA, false, nullptr); + } + // NOTE: slide-up COOP panel intentionally removed. Only the inline + // highscores-area choice buttons are shown when coop setup is active. + // Inline exit HUD (no opaque background) - slides into the highscores area if (exitTransition > 0.0) { float easedE = static_cast(exitTransition); @@ -1466,3 +1841,5 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); } } } + + diff --git a/src/states/MenuState.h b/src/states/MenuState.h index 2fa4f09..20d41ae 100644 --- a/src/states/MenuState.h +++ b/src/states/MenuState.h @@ -19,6 +19,10 @@ public: void showHelpPanel(bool show); // Show or hide the inline ABOUT panel (menu-style) void showAboutPanel(bool show); + + // Show or hide the inline COOPERATE setup panel (2P vs AI). + // If `resumeMusic` is false when hiding, the menu music will not be restarted. + void showCoopSetupPanel(bool show, bool resumeMusic = true); private: int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT @@ -94,4 +98,18 @@ private: double aboutTransition = 0.0; // 0..1 double aboutTransitionDurationMs = 360.0; int aboutDirection = 1; // 1 show, -1 hide + + // Coop setup panel (inline HUD like Exit/Help) + bool coopSetupVisible = false; + bool coopSetupAnimating = false; + double coopSetupTransition = 0.0; // 0..1 + double coopSetupTransitionDurationMs = 320.0; + int coopSetupDirection = 1; // 1 show, -1 hide + int coopSetupSelected = 0; // 0 = 2 players, 1 = AI + SDL_FRect coopSetupBtnRects[2]{}; + bool coopSetupRectsValid = false; + // Optional cooperative info image shown when coop setup panel is active + SDL_Texture* coopInfoTexture = nullptr; + int coopInfoTexW = 0; + int coopInfoTexH = 0; }; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index e5268d0..e6f0138 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -149,27 +149,33 @@ void PlayingState::handleEvent(const SDL_Event& e) { } if (coopActive && ctx.coopGame) { - // Player 1 (left): A/D move via DAS in ApplicationManager; here handle rotations/hold/hard-drop - if (e.key.scancode == SDL_SCANCODE_W) { - ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1); - return; - } - if (e.key.scancode == SDL_SCANCODE_Q) { - ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1); - return; - } - // Hard drop (left): keep LSHIFT, also allow E for convenience. - if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) { - SoundEffectManager::instance().playSound("hard_drop", 0.7f); - ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left); - return; - } - if (e.key.scancode == SDL_SCANCODE_LCTRL) { - ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left); - return; - } + const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI); - // Player 2 (right): arrow keys move via DAS; rotations/hold/hard-drop here + // Player 1 (left): when AI is enabled it controls the left side so + // ignore direct player input for the left board. + if (coopAIEnabled) { + // Left side controlled by AI; skip left-side input handling here. + } else { + // Player 1 manual controls (left side) + if (e.key.scancode == SDL_SCANCODE_W) { + ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1); + return; + } + if (e.key.scancode == SDL_SCANCODE_Q) { + ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1); + return; + } + // Hard drop (left): keep LSHIFT, also allow E for convenience. + if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) { + SoundEffectManager::instance().playSound("hard_drop", 0.7f); + ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left); + return; + } + if (e.key.scancode == SDL_SCANCODE_LCTRL) { + ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left); + return; + } + } if (e.key.scancode == SDL_SCANCODE_UP) { bool upIsCW = Settings::instance().isUpRotateClockwise(); ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1); @@ -183,6 +189,10 @@ void PlayingState::handleEvent(const SDL_Event& e) { 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); + if (coopAIEnabled) { + // Mirror human-initiated hard-drop to AI on left + ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left); + } return; } if (e.key.scancode == SDL_SCANCODE_RCTRL) { diff --git a/src/states/State.h b/src/states/State.h index 6775c4f..032c14f 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -79,6 +79,8 @@ struct StateContext { int* challengeStoryLevel = nullptr; // Cached level for the current story line float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade std::string* playerName = nullptr; // Shared player name buffer for highscores/options + // Coop setting: when true, COOPERATE runs with a computer-controlled right player. + bool* coopVsAI = nullptr; bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available std::function applyFullscreen; // Allows states to request fullscreen changes std::function queryFullscreen; // Optional callback if fullscreenFlag is not reliable diff --git a/src/ui/BottomMenu.cpp b/src/ui/BottomMenu.cpp index 18a25e1..fa7b3cb 100644 --- a/src/ui/BottomMenu.cpp +++ b/src/ui/BottomMenu.cpp @@ -13,7 +13,7 @@ static bool pointInRect(const SDL_FRect& r, float x, float y) { return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h); } -BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) { +BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI) { BottomMenu menu{}; auto rects = computeMenuButtonRects(params); @@ -22,6 +22,7 @@ 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 }; + // Always show a neutral "COOPERATE" label (remove per-mode suffixes) 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 }; diff --git a/src/ui/BottomMenu.h b/src/ui/BottomMenu.h index 75695e5..6c0e3d7 100644 --- a/src/ui/BottomMenu.h +++ b/src/ui/BottomMenu.h @@ -35,7 +35,7 @@ struct BottomMenu { std::array buttons{}; }; -BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel); +BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI); // Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives. // hoveredIndex: -1..7