basic gameplay for cooperative

This commit is contained in:
2025-12-21 15:33:37 +01:00
parent 5b9eb5f0e3
commit afd7fdf18d
20 changed files with 1534 additions and 263 deletions

View File

@ -442,7 +442,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_UP:
{
const int total = 7;
const int total = MENU_BTN_COUNT;
selectedButton = (selectedButton + total - 1) % total;
// brief bright flash on navigation
buttonFlash = 1.0;
@ -451,7 +451,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_DOWN:
{
const int total = 7;
const int total = MENU_BTN_COUNT;
selectedButton = (selectedButton + 1) % total;
// brief bright flash on navigation
buttonFlash = 1.0;
@ -470,6 +470,17 @@ 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();
break;
case 2:
// Start challenge run at level 1
if (ctx.game) {
ctx.game->setMode(GameMode::Challenge);
@ -480,7 +491,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
triggerPlay();
break;
case 2:
case 3:
// Toggle inline level selector HUD (show/hide)
if (!levelPanelVisible && !levelPanelAnimating) {
levelPanelAnimating = true;
@ -492,7 +503,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
levelDirection = -1; // hide
}
break;
case 3:
case 4:
// Toggle the options panel with an animated slide-in/out.
if (!optionsVisible && !optionsAnimating) {
optionsAnimating = true;
@ -502,7 +513,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
optionsDirection = -1; // hide
}
break;
case 4:
case 5:
// Toggle the inline HELP HUD (show/hide)
if (!helpPanelVisible && !helpPanelAnimating) {
helpPanelAnimating = true;
@ -513,7 +524,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
helpDirection = -1; // hide
}
break;
case 5:
case 6:
// Toggle the inline ABOUT HUD (show/hide)
if (!aboutPanelVisible && !aboutPanelAnimating) {
aboutPanelAnimating = true;
@ -523,7 +534,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
aboutDirection = -1;
}
break;
case 6:
case 7:
// Show the inline exit HUD
if (!exitPanelVisible && !exitPanelAnimating) {
exitPanelAnimating = true;

View File

@ -21,7 +21,7 @@ public:
void showAboutPanel(bool show);
private:
int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = HELP, 4 = ABOUT, 5 = EXIT
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT
// Button icons (optional - will use text if nullptr)
SDL_Texture* playIcon = nullptr;

View File

@ -1,6 +1,7 @@
#include "PlayingState.h"
#include "../core/state/StateManager.h"
#include "../gameplay/core/Game.h"
#include "../gameplay/coop/CoopGame.h"
#include "../gameplay/effects/LineEffect.h"
#include "../persistence/Scores.h"
#include "../audio/Audio.h"
@ -18,12 +19,15 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
void PlayingState::onEnter() {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
// Initialize the game based on mode: endless uses chosen start level, challenge keeps its run state
// Initialize the game based on mode: endless/cooperate use chosen start level, challenge keeps its run state
if (ctx.game) {
if (ctx.game->getMode() == GameMode::Endless) {
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
if (ctx.startLevelSelection) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
ctx.game->reset(*ctx.startLevelSelection);
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
ctx.coopGame->reset(*ctx.startLevelSelection);
}
}
} else {
// Challenge run is prepared before entering; ensure gameplay is unpaused
@ -45,124 +49,164 @@ void PlayingState::onExit() {
}
void PlayingState::handleEvent(const SDL_Event& e) {
if (!ctx.game) return;
// If a transport animation is active, ignore gameplay input entirely.
if (GameRenderer::isTransportActive()) {
return;
}
// We keep short-circuited input here; main still owns mouse UI
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (!ctx.game) return;
auto setExitSelection = [&](int value) {
if (ctx.exitPopupSelectedButton) {
*ctx.exitPopupSelectedButton = value;
}
};
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
auto getExitSelection = [&]() -> int {
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
};
auto setExitSelection = [&](int idx) {
if (ctx.exitPopupSelectedButton) {
*ctx.exitPopupSelectedButton = idx;
}
};
auto getExitSelection = [&]() -> int {
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
};
// Pause toggle (P)
if (e.key.scancode == SDL_SCANCODE_P) {
bool paused = ctx.game->isPaused();
ctx.game->setPaused(!paused);
if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) {
return;
}
// If exit-confirm popup is visible, handle shortcuts here
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
// Navigate between YES (0) and NO (1) buttons
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
setExitSelection(0);
return;
}
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
setExitSelection(1);
return;
}
// If exit-confirm popup is visible, handle shortcuts here
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
// Navigate between YES (0) and NO (1) buttons
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
setExitSelection(0);
return;
}
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
setExitSelection(1);
return;
}
// Activate selected button with Enter or Space
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
const bool confirmExit = (getExitSelection() == 0);
*ctx.showExitConfirmPopup = false;
if (confirmExit) {
// YES - Reset game and return to menu
if (ctx.startLevelSelection) {
ctx.game->reset(*ctx.startLevelSelection);
} else {
ctx.game->reset(0);
}
ctx.game->setPaused(false);
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
// Activate selected button with Enter or Space
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
const bool confirmExit = (getExitSelection() == 0);
*ctx.showExitConfirmPopup = false;
if (confirmExit) {
// YES - Reset game and return to menu
if (ctx.startLevelSelection) {
ctx.game->reset(*ctx.startLevelSelection);
} else {
// NO - Just close popup and resume
ctx.game->setPaused(false);
ctx.game->reset(0);
}
return;
}
// Cancel with Esc (same as NO)
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
*ctx.showExitConfirmPopup = false;
ctx.game->setPaused(false);
setExitSelection(1);
return;
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
} else {
// NO - Just close popup and resume
ctx.game->setPaused(false);
}
// While modal is open, suppress other gameplay keys
return;
}
// ESC key - open confirmation popup
// Cancel with Esc (same as NO)
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
if (ctx.showExitConfirmPopup) {
if (ctx.game) ctx.game->setPaused(true);
*ctx.showExitConfirmPopup = true;
setExitSelection(1); // Default to NO for safety
}
return;
}
// Debug: skip to next challenge level (B)
if (e.key.scancode == SDL_SCANCODE_B && ctx.game && ctx.game->getMode() == GameMode::Challenge) {
ctx.game->beginNextChallengeLevel();
// Cancel any countdown so play resumes immediately on the new level
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
*ctx.showExitConfirmPopup = false;
ctx.game->setPaused(false);
setExitSelection(1);
return;
}
// While modal is open, suppress other gameplay keys
return;
}
// ESC key - open confirmation popup
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
if (ctx.showExitConfirmPopup) {
ctx.game->setPaused(true);
*ctx.showExitConfirmPopup = true;
setExitSelection(1); // Default to NO for safety
}
return;
}
// Debug: skip to next challenge level (B)
if (e.key.scancode == SDL_SCANCODE_B && ctx.game->getMode() == GameMode::Challenge) {
ctx.game->beginNextChallengeLevel();
// Cancel any countdown so play resumes immediately on the new level
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
ctx.game->setPaused(false);
return;
}
// Tetris controls (only when not paused)
if (ctx.game->isPaused()) {
return;
}
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;
}
if (e.key.scancode == SDL_SCANCODE_LSHIFT) {
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;
}
// Tetris controls (only when not paused)
if (!ctx.game->isPaused()) {
// Hold / swap current piece (H)
if (e.key.scancode == SDL_SCANCODE_H) {
ctx.game->holdCurrent();
return;
}
// Player 2 (right): arrow keys move via DAS; rotations/hold/hard-drop here
if (e.key.scancode == SDL_SCANCODE_UP) {
bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
return;
}
if (e.key.scancode == SDL_SCANCODE_RALT) {
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, -1);
return;
}
if (e.key.scancode == SDL_SCANCODE_RSHIFT) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
return;
}
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Right);
return;
}
} else {
// Single-player classic controls
// Hold / swap current piece (H)
if (e.key.scancode == SDL_SCANCODE_H) {
ctx.game->holdCurrent();
return;
}
// Rotation (still event-based for precise timing)
if (e.key.scancode == SDL_SCANCODE_UP) {
// Use user setting to determine whether UP rotates clockwise
bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.game->rotate(upIsCW ? 1 : -1);
return;
}
if (e.key.scancode == SDL_SCANCODE_X) {
// Toggle the mapping so UP will rotate in the opposite direction
bool current = Settings::instance().isUpRotateClockwise();
Settings::instance().setUpRotateClockwise(!current);
Settings::instance().save();
// Play a subtle feedback sound if available
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
return;
}
// Rotation (still event-based for precise timing)
if (e.key.scancode == SDL_SCANCODE_UP) {
// Use user setting to determine whether UP rotates clockwise
bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.game->rotate(upIsCW ? 1 : -1);
return;
}
if (e.key.scancode == SDL_SCANCODE_X) {
// Toggle the mapping so UP will rotate in the opposite direction
bool current = Settings::instance().isUpRotateClockwise();
Settings::instance().setUpRotateClockwise(!current);
Settings::instance().save();
// Play a subtle feedback sound if available
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
return;
}
// Hard drop (space)
if (e.key.scancode == SDL_SCANCODE_SPACE) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.game->hardDrop();
return;
}
// Hard drop (space)
if (e.key.scancode == SDL_SCANCODE_SPACE) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.game->hardDrop();
return;
}
}
@ -172,7 +216,21 @@ void PlayingState::handleEvent(const SDL_Event& e) {
void PlayingState::update(double frameMs) {
if (!ctx.game) return;
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
if (coopActive) {
// Visual effects only; gravity and movement handled from ApplicationManager for coop
ctx.coopGame->updateVisualEffects(frameMs);
// Update line clear effect for coop mode as well (renderer starts the effect)
if (ctx.lineEffect && ctx.lineEffect->isActive()) {
if (ctx.lineEffect->update(frameMs / 1000.0f)) {
ctx.coopGame->clearCompletedLines();
}
}
return;
}
ctx.game->updateVisualEffects(frameMs);
// If a transport animation is active, pause gameplay updates and ignore inputs
if (GameRenderer::isTransportActive()) {
@ -204,6 +262,8 @@ void PlayingState::update(double frameMs) {
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
if (!ctx.game) return;
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
// Get current window size
int winW = 0, winH = 0;
SDL_GetRenderOutputSize(renderer, &winW, &winH);
@ -244,26 +304,44 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
// Render game content (no overlays)
// If a transport effect was requested due to a recent spawn, start it here so
// the renderer has the correct layout and renderer context to compute coords.
if (s_pendingTransport) {
if (!coopActive && s_pendingTransport) {
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
s_pendingTransport = false;
}
GameRenderer::renderPlayingState(
renderer,
ctx.game,
ctx.pixelFont,
ctx.lineEffect,
ctx.blocksTex,
ctx.asteroidsTex,
ctx.statisticsPanelTex,
ctx.scorePanelTex,
ctx.nextPanelTex,
ctx.holdPanelTex,
countdown,
1200.0f, // LOGICAL_W
1000.0f, // LOGICAL_H
logicalScale,
if (coopActive && ctx.coopGame) {
GameRenderer::renderCoopPlayingState(
renderer,
ctx.coopGame,
ctx.pixelFont,
ctx.lineEffect,
ctx.blocksTex,
ctx.statisticsPanelTex,
ctx.scorePanelTex,
ctx.nextPanelTex,
ctx.holdPanelTex,
1200.0f,
1000.0f,
logicalScale,
(float)winW,
(float)winH
);
} else {
GameRenderer::renderPlayingState(
renderer,
ctx.game,
ctx.pixelFont,
ctx.lineEffect,
ctx.blocksTex,
ctx.asteroidsTex,
ctx.statisticsPanelTex,
ctx.scorePanelTex,
ctx.nextPanelTex,
ctx.holdPanelTex,
countdown,
1200.0f, // LOGICAL_W
1000.0f, // LOGICAL_H
logicalScale,
(float)winW,
(float)winH,
challengeClearFx,
@ -272,7 +350,8 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
challengeClearDuration,
countdown ? nullptr : ctx.challengeStoryText,
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
);
);
}
// Reset to screen
SDL_SetRenderTarget(renderer, nullptr);
@ -341,33 +420,53 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
} else {
// Render normally directly to screen
if (s_pendingTransport) {
if (!coopActive && s_pendingTransport) {
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
s_pendingTransport = false;
}
GameRenderer::renderPlayingState(
renderer,
ctx.game,
ctx.pixelFont,
ctx.lineEffect,
ctx.blocksTex,
ctx.asteroidsTex,
ctx.statisticsPanelTex,
ctx.scorePanelTex,
ctx.nextPanelTex,
ctx.holdPanelTex,
countdown,
1200.0f,
1000.0f,
logicalScale,
(float)winW,
(float)winH,
challengeClearFx,
challengeClearOrder,
challengeClearElapsed,
challengeClearDuration,
countdown ? nullptr : ctx.challengeStoryText,
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
);
if (coopActive && ctx.coopGame) {
GameRenderer::renderCoopPlayingState(
renderer,
ctx.coopGame,
ctx.pixelFont,
ctx.lineEffect,
ctx.blocksTex,
ctx.statisticsPanelTex,
ctx.scorePanelTex,
ctx.nextPanelTex,
ctx.holdPanelTex,
1200.0f,
1000.0f,
logicalScale,
(float)winW,
(float)winH
);
} else {
GameRenderer::renderPlayingState(
renderer,
ctx.game,
ctx.pixelFont,
ctx.lineEffect,
ctx.blocksTex,
ctx.asteroidsTex,
ctx.statisticsPanelTex,
ctx.scorePanelTex,
ctx.nextPanelTex,
ctx.holdPanelTex,
countdown,
1200.0f,
1000.0f,
logicalScale,
(float)winW,
(float)winH,
challengeClearFx,
challengeClearOrder,
challengeClearElapsed,
challengeClearDuration,
countdown ? nullptr : ctx.challengeStoryText,
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
);
}
}
}

View File

@ -9,6 +9,7 @@
// Forward declarations for frequently used types
class Game;
class CoopGame;
class ScoreManager;
class Starfield;
class Starfield3D;
@ -24,6 +25,7 @@ class StateManager;
struct StateContext {
// Core subsystems (may be null if not available)
Game* game = nullptr;
CoopGame* coopGame = nullptr;
ScoreManager* scores = nullptr;
Starfield* starfield = nullptr;
Starfield3D* starfield3D = nullptr;