Merge branch 'feature/CooperateAiPlayer' into develop

This commit is contained in:
2025-12-23 16:50:51 +01:00
13 changed files with 899 additions and 69 deletions

View File

@ -34,6 +34,7 @@ set(TETRIS_SOURCES
src/app/TetrisApp.cpp src/app/TetrisApp.cpp
src/gameplay/core/Game.cpp src/gameplay/core/Game.cpp
src/gameplay/coop/CoopGame.cpp src/gameplay/coop/CoopGame.cpp
src/gameplay/coop/CoopAIController.cpp
src/core/GravityManager.cpp src/core/GravityManager.cpp
src/core/state/StateManager.cpp src/core/state/StateManager.cpp
# New core architecture classes # New core architecture classes

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

View File

@ -5,7 +5,7 @@
Fullscreen=1 Fullscreen=1
[Audio] [Audio]
Music=0 Music=1
Sound=1 Sound=1
[Gameplay] [Gameplay]
@ -14,7 +14,7 @@ SmoothScroll=1
UpRotateClockwise=0 UpRotateClockwise=0
[Player] [Player]
Name=GREGOR Name=P2
[Debug] [Debug]
Enabled=1 Enabled=1

View File

@ -38,6 +38,7 @@
#include "gameplay/core/Game.h" #include "gameplay/core/Game.h"
#include "gameplay/coop/CoopGame.h" #include "gameplay/coop/CoopGame.h"
#include "gameplay/coop/CoopAIController.h"
#include "gameplay/effects/LineEffect.h" #include "gameplay/effects/LineEffect.h"
#include "graphics/effects/SpaceWarp.h" #include "graphics/effects/SpaceWarp.h"
@ -239,6 +240,11 @@ struct TetrisApp::Impl {
bool suppressLineVoiceForLevelUp = false; bool suppressLineVoiceForLevelUp = false;
bool skipNextLevelUpJingle = false; bool skipNextLevelUpJingle = false;
// COOPERATE option: when true, right player is AI-controlled.
bool coopVsAI = false;
CoopAIController coopAI;
AppState state = AppState::Loading; AppState state = AppState::Loading;
double loadingProgress = 0.0; double loadingProgress = 0.0;
Uint64 loadStart = 0; Uint64 loadStart = 0;
@ -567,6 +573,7 @@ int TetrisApp::Impl::init()
ctx.mainScreenW = mainScreenW; ctx.mainScreenW = mainScreenW;
ctx.mainScreenH = mainScreenH; ctx.mainScreenH = mainScreenH;
ctx.musicEnabled = &musicEnabled; ctx.musicEnabled = &musicEnabled;
ctx.coopVsAI = &coopVsAI;
ctx.startLevelSelection = &startLevelSelection; ctx.startLevelSelection = &startLevelSelection;
ctx.hoveredButton = &hoveredButton; ctx.hoveredButton = &hoveredButton;
ctx.showSettingsPopup = &showSettingsPopup; ctx.showSettingsPopup = &showSettingsPopup;
@ -628,10 +635,17 @@ int TetrisApp::Impl::init()
return; return;
} }
if (state != AppState::Menu) { if (state != AppState::Menu) {
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
coopAI.reset();
}
state = AppState::Playing; state = AppState::Playing;
ctx.stateManager->setState(state); ctx.stateManager->setState(state);
return; return;
} }
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
coopAI.reset();
}
beginStateFade(AppState::Playing, true); beginStateFade(AppState::Playing, true);
}; };
ctx.startPlayTransition = startMenuPlayTransition; 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 (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (isNewHighScore) { if (isNewHighScore) {
if (game && game->getMode() == GameMode::Cooperate && coopGame) { if (game && game->getMode() == GameMode::Cooperate && coopGame) {
// Two-name entry flow if (coopVsAI) {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) { // One-name entry flow (CPU is LEFT, human enters RIGHT name)
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back(); if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back(); if (!player2Name.empty()) player2Name.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { } 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"; if (player2Name.empty()) player2Name = "P2";
// Submit combined name std::string combined = std::string("CPU") + " & " + player2Name;
std::string combined = playerName + " & " + player2Name;
int leftScore = coopGame->score(CoopGame::PlayerSide::Left); int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
int rightScore = coopGame->score(CoopGame::PlayerSide::Right); int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
int combinedScore = leftScore + rightScore; int combinedScore = leftScore + rightScore;
ensureScoresLoaded(); ensureScoresLoaded();
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
Settings::instance().setPlayerName(playerName); Settings::instance().setPlayerName(player2Name);
isNewHighScore = false; isNewHighScore = false;
SDL_StopTextInput(window); 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 { } else {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) { if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
@ -972,11 +1004,9 @@ void TetrisApp::Impl::runLoop()
startMenuPlayTransition(); startMenuPlayTransition();
break; break;
case ui::BottomMenuItem::Cooperate: case ui::BottomMenuItem::Cooperate:
if (game) { if (menuState) {
game->setMode(GameMode::Cooperate); menuState->showCoopSetupPanel(true);
game->reset(startLevelSelection);
} }
startMenuPlayTransition();
break; break;
case ui::BottomMenuItem::Challenge: case ui::BottomMenuItem::Challenge:
if (game) { if (game) {
@ -1288,13 +1318,44 @@ void TetrisApp::Impl::runLoop()
p2LeftHeld = false; p2LeftHeld = false;
p2RightHeld = false; p2RightHeld = false;
} else { } else {
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S); // Define canonical key mappings for left and right players
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN); 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]; const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT;
p1RightHeld = ks[SDL_SCANCODE_D]; const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
p2LeftHeld = ks[SDL_SCANCODE_LEFT]; const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
p2RightHeld = ks[SDL_SCANCODE_RIGHT];
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->tickGravity(frameMs);
coopGame->updateVisualEffects(frameMs); coopGame->updateVisualEffects(frameMs);
@ -1307,14 +1368,22 @@ void TetrisApp::Impl::runLoop()
int combinedScore = leftScore + rightScore; int combinedScore = leftScore + rightScore;
if (combinedScore > 0) { if (combinedScore > 0) {
isNewHighScore = true; isNewHighScore = true;
playerName.clear(); if (coopVsAI) {
player2Name.clear(); // AI is left, prompt human (right) for name
highScoreEntryIndex = 0; playerName = "CPU";
player2Name.clear();
highScoreEntryIndex = 1; // enter P2 (human)
} else {
playerName.clear();
player2Name.clear();
highScoreEntryIndex = 0;
}
SDL_StartTextInput(window); SDL_StartTextInput(window);
} else { } else {
isNewHighScore = false; isNewHighScore = false;
ensureScoresLoaded(); 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; state = AppState::GameOver;
stateMgr->setState(state); stateMgr->setState(state);

View File

@ -0,0 +1,317 @@
#include "CoopAIController.h"
#include "CoopGame.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
namespace {
static bool canPlacePieceForSide(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& 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<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& 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<uint8_t, CoopGame::COLS * CoopGame::ROWS>& 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<double>::infinity();
int rot = 0;
int x = 10;
};
static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) {
const auto& board = game.boardRef();
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> 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<int, 10> 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<double>(fullRows) * 12000.0;
// Heavily prefer completing the player's half — make this a primary objective.
s += static_cast<double>(sideHalfFullRows) * 6000.0;
// Penalize holes and height less aggressively so completing half-rows is prioritized.
s -= static_cast<double>(holes) * 180.0;
s -= static_cast<double>(aggregateHeight) * 4.0;
s -= static_cast<double>(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;
}
}

View File

@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#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);
};

View File

@ -307,9 +307,8 @@ void CoopGame::spawn(PlayerState& ps) {
pieceSequence++; pieceSequence++;
if (collides(ps, ps.cur)) { if (collides(ps, ps.cur)) {
ps.toppedOut = true; ps.toppedOut = true;
if (left.toppedOut && right.toppedOut) { // Cooperative mode: game ends when any player tops out.
gameOver = true; gameOver = true;
}
} }
} }

View File

@ -9,6 +9,8 @@
#include "../audio/Audio.h" #include "../audio/Audio.h"
#include "../audio/SoundEffect.h" #include "../audio/SoundEffect.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_surface.h>
#include <cstdio> #include <cstdio>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
@ -110,6 +112,54 @@ static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP
MenuState::MenuState(StateContext& ctx) : State(ctx) {} 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<int>(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) { void MenuState::showHelpPanel(bool show) {
if (show) { if (show) {
if (!helpPanelVisible && !helpPanelAnimating) { if (!helpPanelVisible && !helpPanelAnimating) {
@ -204,10 +254,11 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale,
}; };
int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; 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 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. // Pulse is encoded as a signed delta so PLAY can dim/brighten while focused.
const double pulseDelta = (buttonPulseAlpha - 1.0); const double pulseDelta = (buttonPulseAlpha - 1.0);
const double flashDelta = buttonFlash * buttonFlashAmount; const double flashDelta = buttonFlash * buttonFlashAmount;
@ -225,9 +276,113 @@ void MenuState::onExit() {
if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; } if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; }
if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; } if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; }
if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; } if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; }
if (coopInfoTexture) { SDL_DestroyTexture(coopInfoTexture); coopInfoTexture = nullptr; }
} }
void MenuState::handleEvent(const SDL_Event& e) { 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<float>(e.button.x);
const float my = static_cast<float>(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 // Keyboard navigation for menu buttons
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { 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. // 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; return;
case SDL_SCANCODE_ESCAPE: case SDL_SCANCODE_ESCAPE:
// Close HUD showCoopSetupPanel(false, true);
exitPanelAnimating = true; exitDirection = -1; // 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; if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false;
return; return;
case SDL_SCANCODE_PAGEDOWN: case SDL_SCANCODE_PAGEDOWN:
@ -457,6 +625,49 @@ void MenuState::handleEvent(const SDL_Event& e) {
return; 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) { switch (e.key.scancode) {
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_UP: case SDL_SCANCODE_UP:
@ -489,15 +700,8 @@ void MenuState::handleEvent(const SDL_Event& e) {
triggerPlay(); triggerPlay();
break; break;
case 1: case 1:
// Cooperative play // Cooperative play: open setup panel (2P vs AI)
if (ctx.game) { showCoopSetupPanel(true);
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; break;
case 2: case 2:
// Start challenge run at level 1 // Start challenge run at level 1
@ -566,6 +770,10 @@ void MenuState::handleEvent(const SDL_Event& e) {
} }
break; break;
case SDL_SCANCODE_ESCAPE: case SDL_SCANCODE_ESCAPE:
if (coopSetupVisible && !coopSetupAnimating) {
showCoopSetupPanel(false, false);
return;
}
// If options panel is visible, hide it first. // If options panel is visible, hide it first.
if (optionsVisible && !optionsAnimating) { if (optionsVisible && !optionsAnimating) {
optionsAnimating = true; 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<double>(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 // Animate level selection highlight position toward the selected cell center
if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) { if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) {
// Recompute same grid geometry used in render to find target center // 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 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. // 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<float>(std::max( float combinedTransition = static_cast<float>(std::max(
std::max(std::max(optionsTransition, levelTransition), exitTransition), std::max(std::max(optionsTransition, levelTransition), exitTransition),
std::max(helpTransition, aboutTransition) 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 // Small label under the logo — show "COOPERATE" when coop setup is active
const std::string smallTitle = "TOP PLAYER"; const std::string smallTitle = (coopSetupAnimating || coopSetupVisible) ? "COOPERATE" : "TOP PLAYER";
float titleScale = 0.9f; float titleScale = 0.9f;
int tW = 0, tH = 0; int tW = 0, tH = 0;
useFont->measure(smallTitle, titleScale, tW, tH); 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 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 // 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 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 const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows
// Shift the entire highscores panel slightly left (~1.5% of logical width) // 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<float>(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<float>(coopInfoTexW));
float scale = targetW / static_cast<float>(coopInfoTexW);
float targetH = static_cast<float>(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<Uint8>(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<std::string> 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<Uint8>(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<float>(hW)) * 0.5f + 40.0f; // nudge header right by 40px
float headerY = textY - static_cast<float>(sampleLH);
SDL_Color headerCol = SDL_Color{220,240,255,230}; headerCol.a = static_cast<Uint8>(std::round(headerCol.a * alphaFactor));
f->draw(renderer, hx, headerY, header, headerScale, headerCol);
// Start body text slightly below header
textY = headerY + static_cast<float>(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<float>(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<Uint8>(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<float>(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<Uint8>(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<Uint8>(std::round(bgA.a * buttonFade));
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(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 // Inline exit HUD (no opaque background) - slides into the highscores area
if (exitTransition > 0.0) { if (exitTransition > 0.0) {
float easedE = static_cast<float>(exitTransition); float easedE = static_cast<float>(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); } FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); }
} }
} }

View File

@ -20,6 +20,10 @@ public:
// Show or hide the inline ABOUT panel (menu-style) // Show or hide the inline ABOUT panel (menu-style)
void showAboutPanel(bool show); 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: private:
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT 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 aboutTransition = 0.0; // 0..1
double aboutTransitionDurationMs = 360.0; double aboutTransitionDurationMs = 360.0;
int aboutDirection = 1; // 1 show, -1 hide 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;
}; };

View File

@ -149,27 +149,33 @@ void PlayingState::handleEvent(const SDL_Event& e) {
} }
if (coopActive && ctx.coopGame) { if (coopActive && ctx.coopGame) {
// Player 1 (left): A/D move via DAS in ApplicationManager; here handle rotations/hold/hard-drop const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
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;
}
// 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) { if (e.key.scancode == SDL_SCANCODE_UP) {
bool upIsCW = Settings::instance().isUpRotateClockwise(); bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1); 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) { if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f); SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right); ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
if (coopAIEnabled) {
// Mirror human-initiated hard-drop to AI on left
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
}
return; return;
} }
if (e.key.scancode == SDL_SCANCODE_RCTRL) { if (e.key.scancode == SDL_SCANCODE_RCTRL) {

View File

@ -79,6 +79,8 @@ struct StateContext {
int* challengeStoryLevel = nullptr; // Cached level for the current story line int* challengeStoryLevel = nullptr; // Cached level for the current story line
float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade
std::string* playerName = nullptr; // Shared player name buffer for highscores/options 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 bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable

View File

@ -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); 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{}; BottomMenu menu{};
auto rects = computeMenuButtonRects(params); auto rects = computeMenuButtonRects(params);
@ -22,6 +22,7 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false }; 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[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false }; menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true }; menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };

View File

@ -35,7 +35,7 @@ struct BottomMenu {
std::array<Button, MENU_BTN_COUNT> buttons{}; std::array<Button, MENU_BTN_COUNT> 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. // Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
// hoveredIndex: -1..7 // hoveredIndex: -1..7