fixed cooperate play
This commit is contained in:
@ -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);
|
||||
|
||||
317
src/gameplay/coop/CoopAIController.cpp
Normal file
317
src/gameplay/coop/CoopAIController.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/gameplay/coop/CoopAIController.h
Normal file
36
src/gameplay/coop/CoopAIController.h
Normal 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);
|
||||
};
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user