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/settings.ini b/settings.ini index 86008f0..99029f7 100644 --- a/settings.ini +++ b/settings.ini @@ -5,7 +5,7 @@ Fullscreen=1 [Audio] -Music=0 +Music=1 Sound=1 [Gameplay] 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/states/MenuState.cpp b/src/states/MenuState.cpp index fc63209..82cbea5 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -110,6 +110,54 @@ static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP MenuState::MenuState(StateContext& ctx) : State(ctx) {} +void MenuState::showCoopSetupPanel(bool show) { + 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; + // Ensure menu music resumes when closing the coop setup panel + if (ctx.musicEnabled && *ctx.musicEnabled) { + Audio::instance().playMenuMusic(); + } + } + } +} + void MenuState::showHelpPanel(bool show) { if (show) { if (!helpPanelVisible && !helpPanelAnimating) { @@ -204,7 +252,8 @@ 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; @@ -228,6 +277,48 @@ void MenuState::onExit() { } void MenuState::handleEvent(const SDL_Event& e) { + // 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) { + 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. @@ -457,6 +548,47 @@ 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: + case SDL_SCANCODE_A: + coopSetupSelected = 0; + buttonFlash = 1.0; + return; + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_D: + coopSetupSelected = 1; + buttonFlash = 1.0; + return; + case SDL_SCANCODE_ESCAPE: + showCoopSetupPanel(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; + } + // Start 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); + } + showCoopSetupPanel(false); + triggerPlay(); + return; + } + default: + break; + } + } + switch (e.key.scancode) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_UP: @@ -489,15 +621,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 +691,10 @@ void MenuState::handleEvent(const SDL_Event& e) { } break; case SDL_SCANCODE_ESCAPE: + if (coopSetupVisible && !coopSetupAnimating) { + showCoopSetupPanel(false); + return; + } // If options panel is visible, hide it first. if (optionsVisible && !optionsAnimating) { optionsAnimating = true; @@ -665,6 +794,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 +934,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) @@ -848,7 +994,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 +1260,46 @@ 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 larger and center them vertically in the highscores area + const float btnW2 = std::min(420.0f, panelW * 0.44f); + const float btnH2 = 84.0f; + const float gap = 28.0f; + const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f; + const float by = panelBaseY + (panelH - btnH2) * 0.5f; + + 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 }; + UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[0].x + btnW2 * 0.5f, coopSetupBtnRects[0].y + btnH2 * 0.5f, + btnW2, btnH2, "2 PLAYERS", false, coopSetupSelected == 0, bg, border, 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, bg, border, 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 +1654,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..e148bee 100644 --- a/src/states/MenuState.h +++ b/src/states/MenuState.h @@ -19,6 +19,9 @@ 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). + void showCoopSetupPanel(bool show); private: int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT @@ -94,4 +97,14 @@ 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; }; 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..1151f1c 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,7 +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 }; - menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false }; + menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], coopVsAI ? "COOPERATE (AI)" : "COOPERATE (2P)", 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 }; 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