fixed cooperate play

This commit is contained in:
2025-12-22 21:26:56 +01:00
parent c14e305a4a
commit 953d6af701
11 changed files with 699 additions and 61 deletions

View File

@ -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);

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

@ -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<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;
// 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<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
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<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
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<float>(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<float>(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); }
}
}

View File

@ -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;
};

View File

@ -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) {

View File

@ -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<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
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);
}
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 };

View File

@ -35,7 +35,7 @@ struct BottomMenu {
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.
// hoveredIndex: -1..7