basic gameplay for cooperative
This commit is contained in:
@ -33,6 +33,7 @@ set(TETRIS_SOURCES
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/app/TetrisApp.cpp
|
src/app/TetrisApp.cpp
|
||||||
src/gameplay/core/Game.cpp
|
src/gameplay/core/Game.cpp
|
||||||
|
src/gameplay/coop/CoopGame.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
|
||||||
|
|||||||
@ -144,4 +144,7 @@ void draw(SDL_Renderer* renderer, SDL_Texture*) {
|
|||||||
|
|
||||||
double getLogoAnimCounter() { return logoAnimCounter; }
|
double getLogoAnimCounter() { return logoAnimCounter; }
|
||||||
int getHoveredButton() { return hoveredButton; }
|
int getHoveredButton() { return hoveredButton; }
|
||||||
|
void spawn(float x, float y) {
|
||||||
|
fireworks.emplace_back(x, y);
|
||||||
|
}
|
||||||
} // namespace AppFireworks
|
} // namespace AppFireworks
|
||||||
|
|||||||
@ -6,4 +6,5 @@ namespace AppFireworks {
|
|||||||
void update(double frameMs);
|
void update(double frameMs);
|
||||||
double getLogoAnimCounter();
|
double getLogoAnimCounter();
|
||||||
int getHoveredButton();
|
int getHoveredButton();
|
||||||
|
void spawn(float x, float y);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
#include "core/state/StateManager.h"
|
#include "core/state/StateManager.h"
|
||||||
|
|
||||||
#include "gameplay/core/Game.h"
|
#include "gameplay/core/Game.h"
|
||||||
|
#include "gameplay/coop/CoopGame.h"
|
||||||
#include "gameplay/effects/LineEffect.h"
|
#include "gameplay/effects/LineEffect.h"
|
||||||
|
|
||||||
#include "graphics/effects/SpaceWarp.h"
|
#include "graphics/effects/SpaceWarp.h"
|
||||||
@ -228,6 +229,7 @@ struct TetrisApp::Impl {
|
|||||||
std::atomic<size_t> loadingStep{0};
|
std::atomic<size_t> loadingStep{0};
|
||||||
|
|
||||||
std::unique_ptr<Game> game;
|
std::unique_ptr<Game> game;
|
||||||
|
std::unique_ptr<CoopGame> coopGame;
|
||||||
std::vector<std::string> singleSounds;
|
std::vector<std::string> singleSounds;
|
||||||
std::vector<std::string> doubleSounds;
|
std::vector<std::string> doubleSounds;
|
||||||
std::vector<std::string> tripleSounds;
|
std::vector<std::string> tripleSounds;
|
||||||
@ -242,7 +244,13 @@ struct TetrisApp::Impl {
|
|||||||
bool isFullscreen = false;
|
bool isFullscreen = false;
|
||||||
bool leftHeld = false;
|
bool leftHeld = false;
|
||||||
bool rightHeld = false;
|
bool rightHeld = false;
|
||||||
|
bool p1LeftHeld = false;
|
||||||
|
bool p1RightHeld = false;
|
||||||
|
bool p2LeftHeld = false;
|
||||||
|
bool p2RightHeld = false;
|
||||||
double moveTimerMs = 0.0;
|
double moveTimerMs = 0.0;
|
||||||
|
double p1MoveTimerMs = 0.0;
|
||||||
|
double p2MoveTimerMs = 0.0;
|
||||||
double DAS = 170.0;
|
double DAS = 170.0;
|
||||||
double ARR = 40.0;
|
double ARR = 40.0;
|
||||||
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
||||||
@ -421,6 +429,8 @@ int TetrisApp::Impl::init()
|
|||||||
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
||||||
game->reset(startLevelSelection);
|
game->reset(startLevelSelection);
|
||||||
|
|
||||||
|
coopGame = std::make_unique<CoopGame>(startLevelSelection);
|
||||||
|
|
||||||
// Define voice line banks for gameplay callbacks
|
// Define voice line banks for gameplay callbacks
|
||||||
singleSounds = {"well_played", "smooth_clear", "great_move"};
|
singleSounds = {"well_played", "smooth_clear", "great_move"};
|
||||||
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
||||||
@ -479,7 +489,10 @@ int TetrisApp::Impl::init()
|
|||||||
isFullscreen = Settings::instance().isFullscreen();
|
isFullscreen = Settings::instance().isFullscreen();
|
||||||
leftHeld = false;
|
leftHeld = false;
|
||||||
rightHeld = false;
|
rightHeld = false;
|
||||||
|
p1LeftHeld = p1RightHeld = p2LeftHeld = p2RightHeld = false;
|
||||||
moveTimerMs = 0;
|
moveTimerMs = 0;
|
||||||
|
p1MoveTimerMs = 0.0;
|
||||||
|
p2MoveTimerMs = 0.0;
|
||||||
DAS = 170.0;
|
DAS = 170.0;
|
||||||
ARR = 40.0;
|
ARR = 40.0;
|
||||||
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
|
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
|
||||||
@ -506,6 +519,7 @@ int TetrisApp::Impl::init()
|
|||||||
ctx = StateContext{};
|
ctx = StateContext{};
|
||||||
ctx.stateManager = stateMgr.get();
|
ctx.stateManager = stateMgr.get();
|
||||||
ctx.game = game.get();
|
ctx.game = game.get();
|
||||||
|
ctx.coopGame = coopGame.get();
|
||||||
ctx.scores = nullptr;
|
ctx.scores = nullptr;
|
||||||
ctx.starfield = &starfield;
|
ctx.starfield = &starfield;
|
||||||
ctx.starfield3D = &starfield3D;
|
ctx.starfield3D = &starfield3D;
|
||||||
@ -858,6 +872,9 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||||
if (game->getMode() == GameMode::Challenge) {
|
if (game->getMode() == GameMode::Challenge) {
|
||||||
game->startChallengeRun(1);
|
game->startChallengeRun(1);
|
||||||
|
} else if (game->getMode() == GameMode::Cooperate) {
|
||||||
|
game->setMode(GameMode::Cooperate);
|
||||||
|
game->reset(startLevelSelection);
|
||||||
} else {
|
} else {
|
||||||
game->setMode(GameMode::Endless);
|
game->setMode(GameMode::Endless);
|
||||||
game->reset(startLevelSelection);
|
game->reset(startLevelSelection);
|
||||||
@ -893,6 +910,13 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (game) game->setMode(GameMode::Endless);
|
if (game) game->setMode(GameMode::Endless);
|
||||||
startMenuPlayTransition();
|
startMenuPlayTransition();
|
||||||
break;
|
break;
|
||||||
|
case ui::BottomMenuItem::Cooperate:
|
||||||
|
if (game) {
|
||||||
|
game->setMode(GameMode::Cooperate);
|
||||||
|
game->reset(startLevelSelection);
|
||||||
|
}
|
||||||
|
startMenuPlayTransition();
|
||||||
|
break;
|
||||||
case ui::BottomMenuItem::Challenge:
|
case ui::BottomMenuItem::Challenge:
|
||||||
if (game) {
|
if (game) {
|
||||||
game->setMode(GameMode::Challenge);
|
game->setMode(GameMode::Challenge);
|
||||||
@ -1153,29 +1177,88 @@ void TetrisApp::Impl::runLoop()
|
|||||||
|
|
||||||
if (state == AppState::Playing)
|
if (state == AppState::Playing)
|
||||||
{
|
{
|
||||||
if (!game->isPaused()) {
|
const bool coopActive = game && game->getMode() == GameMode::Cooperate && coopGame;
|
||||||
game->tickGravity(frameMs);
|
|
||||||
game->updateElapsedTime();
|
|
||||||
|
|
||||||
if (lineEffect.isActive()) {
|
if (coopActive) {
|
||||||
if (lineEffect.update(frameMs / 1000.0f)) {
|
// Coop DAS/ARR handling (per-side)
|
||||||
game->clearCompletedLines();
|
const bool* ks = SDL_GetKeyboardState(nullptr);
|
||||||
|
|
||||||
|
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||||
|
bool leftHeldPrev,
|
||||||
|
bool rightHeldPrev,
|
||||||
|
double& timer,
|
||||||
|
SDL_Scancode leftKey,
|
||||||
|
SDL_Scancode rightKey,
|
||||||
|
SDL_Scancode downKey) {
|
||||||
|
bool left = ks[leftKey];
|
||||||
|
bool right = ks[rightKey];
|
||||||
|
bool down = ks[downKey];
|
||||||
|
|
||||||
|
coopGame->setSoftDropping(side, down);
|
||||||
|
|
||||||
|
int moveDir = 0;
|
||||||
|
if (left && !right) moveDir = -1;
|
||||||
|
else if (right && !left) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= frameMs;
|
||||||
|
if (timer <= 0) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||||
|
p1RightHeld = ks[SDL_SCANCODE_D];
|
||||||
|
p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||||
|
p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||||
|
|
||||||
|
if (!game->isPaused()) {
|
||||||
|
coopGame->tickGravity(frameMs);
|
||||||
|
coopGame->updateVisualEffects(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coopGame->isGameOver()) {
|
||||||
|
state = AppState::GameOver;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if (!game->isPaused()) {
|
||||||
|
game->tickGravity(frameMs);
|
||||||
|
game->updateElapsedTime();
|
||||||
|
|
||||||
|
if (lineEffect.isActive()) {
|
||||||
|
if (lineEffect.update(frameMs / 1000.0f)) {
|
||||||
|
game->clearCompletedLines();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (game->isGameOver())
|
||||||
if (game->isGameOver())
|
{
|
||||||
{
|
if (game->score() > 0) {
|
||||||
if (game->score() > 0) {
|
isNewHighScore = true;
|
||||||
isNewHighScore = true;
|
playerName.clear();
|
||||||
playerName.clear();
|
SDL_StartTextInput(window);
|
||||||
SDL_StartTextInput(window);
|
} else {
|
||||||
} else {
|
isNewHighScore = false;
|
||||||
isNewHighScore = false;
|
ensureScoresLoaded();
|
||||||
ensureScoresLoaded();
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed());
|
||||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed());
|
}
|
||||||
|
state = AppState::GameOver;
|
||||||
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
state = AppState::GameOver;
|
|
||||||
stateMgr->setState(state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (state == AppState::Loading)
|
else if (state == AppState::Loading)
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
#include "../../graphics/effects/Starfield.h"
|
#include "../../graphics/effects/Starfield.h"
|
||||||
#include "../../graphics/renderers/GameRenderer.h"
|
#include "../../graphics/renderers/GameRenderer.h"
|
||||||
#include "../../gameplay/core/Game.h"
|
#include "../../gameplay/core/Game.h"
|
||||||
|
#include "../../gameplay/coop/CoopGame.h"
|
||||||
#include "../../gameplay/effects/LineEffect.h"
|
#include "../../gameplay/effects/LineEffect.h"
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
#include <SDL3_image/SDL_image.h>
|
#include <SDL3_image/SDL_image.h>
|
||||||
@ -561,6 +562,7 @@ bool ApplicationManager::initializeGame() {
|
|||||||
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
||||||
}
|
}
|
||||||
m_game = std::make_unique<Game>(m_startLevelSelection);
|
m_game = std::make_unique<Game>(m_startLevelSelection);
|
||||||
|
m_coopGame = std::make_unique<CoopGame>(m_startLevelSelection);
|
||||||
// Wire up sound callbacks as main.cpp did
|
// Wire up sound callbacks as main.cpp did
|
||||||
if (m_game) {
|
if (m_game) {
|
||||||
// Apply global gravity speed multiplier from config
|
// Apply global gravity speed multiplier from config
|
||||||
@ -580,6 +582,18 @@ bool ApplicationManager::initializeGame() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m_coopGame) {
|
||||||
|
// TODO: tune gravity with Config and shared level scaling once coop rules are finalized
|
||||||
|
m_coopGame->reset(m_startLevelSelection);
|
||||||
|
// Wire coop sound callback to reuse same clear-line VO/SFX behavior
|
||||||
|
m_coopGame->setSoundCallback([&](int linesCleared){
|
||||||
|
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||||
|
if (linesCleared == 2) SoundEffectManager::instance().playRandomSound({"nice_combo"}, 1.0f);
|
||||||
|
else if (linesCleared == 3) SoundEffectManager::instance().playRandomSound({"great_move"}, 1.0f);
|
||||||
|
else if (linesCleared == 4) SoundEffectManager::instance().playRandomSound({"amazing"}, 1.0f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare a StateContext-like struct by setting up handlers that capture
|
// Prepare a StateContext-like struct by setting up handlers that capture
|
||||||
// pointers and flags. State objects in this refactor expect these to be
|
// pointers and flags. State objects in this refactor expect these to be
|
||||||
// available via StateManager event/update/render hooks, so we'll store them
|
// available via StateManager event/update/render hooks, so we'll store them
|
||||||
@ -621,6 +635,7 @@ bool ApplicationManager::initializeGame() {
|
|||||||
{
|
{
|
||||||
m_stateContext.stateManager = m_stateManager.get();
|
m_stateContext.stateManager = m_stateManager.get();
|
||||||
m_stateContext.game = m_game.get();
|
m_stateContext.game = m_game.get();
|
||||||
|
m_stateContext.coopGame = m_coopGame.get();
|
||||||
m_stateContext.scores = m_scoreManager.get();
|
m_stateContext.scores = m_scoreManager.get();
|
||||||
m_stateContext.starfield = m_starfield.get();
|
m_stateContext.starfield = m_starfield.get();
|
||||||
m_stateContext.starfield3D = m_starfield3D.get();
|
m_stateContext.starfield3D = m_starfield3D.get();
|
||||||
@ -1237,74 +1252,144 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_stateManager->registerUpdateHandler(AppState::Playing,
|
m_stateManager->registerUpdateHandler(AppState::Playing,
|
||||||
[this](double frameMs) {
|
[this](double frameMs) {
|
||||||
if (!m_stateContext.game) return;
|
if (!m_stateContext.game) return;
|
||||||
|
|
||||||
|
const bool coopActive = m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame;
|
||||||
|
|
||||||
// Get current keyboard state
|
// Get current keyboard state
|
||||||
const bool *ks = SDL_GetKeyboardState(nullptr);
|
const bool *ks = SDL_GetKeyboardState(nullptr);
|
||||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
|
||||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
|
||||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
|
||||||
|
|
||||||
// Handle soft drop
|
|
||||||
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
|
||||||
|
|
||||||
// Handle DAS/ARR movement timing (from original main.cpp)
|
|
||||||
int moveDir = 0;
|
|
||||||
if (left && !right)
|
|
||||||
moveDir = -1;
|
|
||||||
else if (right && !left)
|
|
||||||
moveDir = +1;
|
|
||||||
|
|
||||||
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
|
||||||
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
|
||||||
// First press - immediate movement
|
|
||||||
m_stateContext.game->move(moveDir);
|
|
||||||
m_moveTimerMs = DAS; // Set initial delay
|
|
||||||
} else {
|
|
||||||
// Key held - handle repeat timing
|
|
||||||
m_moveTimerMs -= frameMs;
|
|
||||||
if (m_moveTimerMs <= 0) {
|
|
||||||
m_stateContext.game->move(moveDir);
|
|
||||||
m_moveTimerMs += ARR; // Set repeat rate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m_moveTimerMs = 0; // Reset timer when no movement
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update held state for next frame
|
|
||||||
m_leftHeld = left;
|
|
||||||
m_rightHeld = right;
|
|
||||||
|
|
||||||
// Handle soft drop boost
|
|
||||||
if (down && !m_stateContext.game->isPaused()) {
|
|
||||||
m_stateContext.game->softDropBoost(frameMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to PlayingState for other updates (gravity, line effects)
|
|
||||||
if (m_playingState) {
|
|
||||||
m_playingState->update(frameMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update background fade progression (match main.cpp semantics approx)
|
if (coopActive) {
|
||||||
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||||
const float LEVEL_FADE_DURATION = 1200.0f;
|
bool leftHeld,
|
||||||
if (m_nextLevelBackgroundTex) {
|
bool rightHeld,
|
||||||
m_levelFadeElapsed += (float)frameMs;
|
double& timer,
|
||||||
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
SDL_Scancode leftKey,
|
||||||
}
|
SDL_Scancode rightKey,
|
||||||
|
SDL_Scancode downKey) {
|
||||||
// Check for game over and transition to GameOver state
|
bool left = ks[leftKey];
|
||||||
if (m_stateContext.game->isGameOver()) {
|
bool right = ks[rightKey];
|
||||||
// Submit score before transitioning
|
bool down = ks[downKey];
|
||||||
if (m_stateContext.scores) {
|
|
||||||
m_stateContext.scores->submit(
|
// Soft drop flag
|
||||||
m_stateContext.game->score(),
|
m_stateContext.coopGame->setSoftDropping(side, down);
|
||||||
m_stateContext.game->lines(),
|
|
||||||
m_stateContext.game->level(),
|
int moveDir = 0;
|
||||||
m_stateContext.game->elapsed()
|
if (left && !right) moveDir = -1;
|
||||||
);
|
else if (right && !left) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeld) || (moveDir == +1 && !rightHeld)) {
|
||||||
|
// First press - immediate movement
|
||||||
|
m_stateContext.coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= frameMs;
|
||||||
|
if (timer <= 0) {
|
||||||
|
m_stateContext.coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft drop boost: coop uses same gravity path; fall acceleration handled inside tickGravity
|
||||||
|
};
|
||||||
|
|
||||||
|
// Left player (WASD): A/D horizontal, S soft drop
|
||||||
|
handleSide(CoopGame::PlayerSide::Left, m_p1LeftHeld, m_p1RightHeld, m_p1MoveTimerMs,
|
||||||
|
SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
||||||
|
// Right player (arrows): Left/Right horizontal, Down soft drop
|
||||||
|
handleSide(CoopGame::PlayerSide::Right, m_p2LeftHeld, m_p2RightHeld, m_p2MoveTimerMs,
|
||||||
|
SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
||||||
|
|
||||||
|
// Update held flags for next frame
|
||||||
|
m_p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||||
|
m_p1RightHeld = ks[SDL_SCANCODE_D];
|
||||||
|
m_p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||||
|
m_p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||||
|
|
||||||
|
// Gravity / effects
|
||||||
|
m_stateContext.coopGame->tickGravity(frameMs);
|
||||||
|
m_stateContext.coopGame->updateVisualEffects(frameMs);
|
||||||
|
|
||||||
|
// Delegate to PlayingState for any ancillary updates (renderer transport bookkeeping)
|
||||||
|
if (m_playingState) {
|
||||||
|
m_playingState->update(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game over transition for coop
|
||||||
|
if (m_stateContext.coopGame->isGameOver()) {
|
||||||
|
m_stateManager->setState(AppState::GameOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||||
|
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||||
|
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||||
|
|
||||||
|
// Handle soft drop
|
||||||
|
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
||||||
|
|
||||||
|
// Handle DAS/ARR movement timing (from original main.cpp)
|
||||||
|
int moveDir = 0;
|
||||||
|
if (left && !right)
|
||||||
|
moveDir = -1;
|
||||||
|
else if (right && !left)
|
||||||
|
moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
||||||
|
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
||||||
|
// First press - immediate movement
|
||||||
|
m_stateContext.game->move(moveDir);
|
||||||
|
m_moveTimerMs = DAS; // Set initial delay
|
||||||
|
} else {
|
||||||
|
// Key held - handle repeat timing
|
||||||
|
m_moveTimerMs -= frameMs;
|
||||||
|
if (m_moveTimerMs <= 0) {
|
||||||
|
m_stateContext.game->move(moveDir);
|
||||||
|
m_moveTimerMs += ARR; // Set repeat rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m_moveTimerMs = 0; // Reset timer when no movement
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update held state for next frame
|
||||||
|
m_leftHeld = left;
|
||||||
|
m_rightHeld = right;
|
||||||
|
|
||||||
|
// Handle soft drop boost
|
||||||
|
if (down && !m_stateContext.game->isPaused()) {
|
||||||
|
m_stateContext.game->softDropBoost(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to PlayingState for other updates (gravity, line effects)
|
||||||
|
if (m_playingState) {
|
||||||
|
m_playingState->update(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update background fade progression (match main.cpp semantics approx)
|
||||||
|
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
||||||
|
const float LEVEL_FADE_DURATION = 1200.0f;
|
||||||
|
if (m_nextLevelBackgroundTex) {
|
||||||
|
m_levelFadeElapsed += (float)frameMs;
|
||||||
|
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for game over and transition to GameOver state
|
||||||
|
if (m_stateContext.game->isGameOver()) {
|
||||||
|
// Submit score before transitioning
|
||||||
|
if (m_stateContext.scores) {
|
||||||
|
m_stateContext.scores->submit(
|
||||||
|
m_stateContext.game->score(),
|
||||||
|
m_stateContext.game->lines(),
|
||||||
|
m_stateContext.game->level(),
|
||||||
|
m_stateContext.game->elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
m_stateManager->setState(AppState::GameOver);
|
||||||
}
|
}
|
||||||
m_stateManager->setState(AppState::GameOver);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Debug overlay: show current window and logical sizes on the right side of the screen
|
// Debug overlay: show current window and logical sizes on the right side of the screen
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class Starfield;
|
|||||||
class Starfield3D;
|
class Starfield3D;
|
||||||
class FontAtlas;
|
class FontAtlas;
|
||||||
class LineEffect;
|
class LineEffect;
|
||||||
|
class CoopGame;
|
||||||
|
|
||||||
// Forward declare state classes (top-level, defined under src/states)
|
// Forward declare state classes (top-level, defined under src/states)
|
||||||
class LoadingState;
|
class LoadingState;
|
||||||
@ -109,6 +110,7 @@ private:
|
|||||||
std::unique_ptr<ScoreManager> m_scoreManager;
|
std::unique_ptr<ScoreManager> m_scoreManager;
|
||||||
// Gameplay pieces
|
// Gameplay pieces
|
||||||
std::unique_ptr<Game> m_game;
|
std::unique_ptr<Game> m_game;
|
||||||
|
std::unique_ptr<CoopGame> m_coopGame;
|
||||||
std::unique_ptr<LineEffect> m_lineEffect;
|
std::unique_ptr<LineEffect> m_lineEffect;
|
||||||
|
|
||||||
// DAS/ARR movement timing (from original main.cpp)
|
// DAS/ARR movement timing (from original main.cpp)
|
||||||
@ -118,6 +120,14 @@ private:
|
|||||||
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
||||||
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
||||||
|
|
||||||
|
// Coop DAS/ARR per player
|
||||||
|
bool m_p1LeftHeld = false;
|
||||||
|
bool m_p1RightHeld = false;
|
||||||
|
bool m_p2LeftHeld = false;
|
||||||
|
bool m_p2RightHeld = false;
|
||||||
|
double m_p1MoveTimerMs = 0.0;
|
||||||
|
double m_p2MoveTimerMs = 0.0;
|
||||||
|
|
||||||
// State context (must be a member to ensure lifetime)
|
// State context (must be a member to ensure lifetime)
|
||||||
StateContext m_stateContext;
|
StateContext m_stateContext;
|
||||||
|
|
||||||
|
|||||||
423
src/gameplay/coop/CoopGame.cpp
Normal file
423
src/gameplay/coop/CoopGame.cpp
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
#include "CoopGame.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
||||||
|
constexpr double NES_FPS = 60.0988;
|
||||||
|
constexpr double FRAME_MS = 1000.0 / NES_FPS;
|
||||||
|
|
||||||
|
struct LevelGravity { int framesPerCell; double levelMultiplier; };
|
||||||
|
|
||||||
|
LevelGravity LEVEL_TABLE[30] = {
|
||||||
|
{48,1.0}, {43,1.0}, {38,1.0}, {33,1.0}, {28,1.0}, {23,1.0}, {18,1.0}, {13,1.0}, {8,1.0}, {6,1.0},
|
||||||
|
{5,1.0}, {5,1.0}, {5,1.0}, {4,1.0}, {4,1.0}, {4,1.0}, {3,1.0}, {3,1.0}, {3,1.0}, {2,1.0},
|
||||||
|
{2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {1,1.0}
|
||||||
|
};
|
||||||
|
|
||||||
|
inline double gravityMsForLevelInternal(int level, double globalMultiplier) {
|
||||||
|
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
|
||||||
|
const LevelGravity& lg = LEVEL_TABLE[idx];
|
||||||
|
double frames = lg.framesPerCell * lg.levelMultiplier;
|
||||||
|
return frames * FRAME_MS * globalMultiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0).
|
||||||
|
static const std::array<Shape, PIECE_COUNT> SHAPES = {{
|
||||||
|
Shape{ 0x0F00, 0x2222, 0x00F0, 0x4444 }, // I
|
||||||
|
Shape{ 0x0660, 0x0660, 0x0660, 0x0660 }, // O
|
||||||
|
Shape{ 0x0E40, 0x4C40, 0x4E00, 0x4640 }, // T
|
||||||
|
Shape{ 0x06C0, 0x4620, 0x06C0, 0x4620 }, // S
|
||||||
|
Shape{ 0x0C60, 0x2640, 0x0C60, 0x2640 }, // Z
|
||||||
|
Shape{ 0x08E0, 0x6440, 0x0E20, 0x44C0 }, // J
|
||||||
|
Shape{ 0x02E0, 0x4460, 0x0E80, 0xC440 }, // L
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
CoopGame::CoopGame(int startLevel_) {
|
||||||
|
reset(startLevel_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::reset(int startLevel_) {
|
||||||
|
std::fill(board.begin(), board.end(), Cell{});
|
||||||
|
rowStates.fill(RowHalfState{});
|
||||||
|
completedLines.clear();
|
||||||
|
hardDropCells.clear();
|
||||||
|
hardDropFxId = 0;
|
||||||
|
hardDropShakeTimerMs = 0.0;
|
||||||
|
_score = 0;
|
||||||
|
_lines = 0;
|
||||||
|
_level = startLevel_;
|
||||||
|
startLevel = startLevel_;
|
||||||
|
gravityMs = gravityMsForLevel(_level);
|
||||||
|
gameOver = false;
|
||||||
|
pieceSequence = 0;
|
||||||
|
|
||||||
|
left = PlayerState{};
|
||||||
|
right = PlayerState{ PlayerSide::Right };
|
||||||
|
|
||||||
|
auto initPlayer = [&](PlayerState& ps) {
|
||||||
|
ps.canHold = true;
|
||||||
|
ps.hold.type = PIECE_COUNT;
|
||||||
|
ps.softDropping = false;
|
||||||
|
ps.toppedOut = false;
|
||||||
|
ps.fallAcc = 0.0;
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
ps.pieceSeq = 0;
|
||||||
|
ps.bag.clear();
|
||||||
|
ps.next.type = PIECE_COUNT;
|
||||||
|
refillBag(ps);
|
||||||
|
};
|
||||||
|
initPlayer(left);
|
||||||
|
initPlayer(right);
|
||||||
|
|
||||||
|
spawn(left);
|
||||||
|
spawn(right);
|
||||||
|
updateRowStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
||||||
|
double oldStep = stepFor(ps.softDropping);
|
||||||
|
double newStep = stepFor(on);
|
||||||
|
if (oldStep <= 0.0 || newStep <= 0.0) {
|
||||||
|
ps.softDropping = on;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double progress = ps.fallAcc / oldStep;
|
||||||
|
progress = std::clamp(progress, 0.0, 1.0);
|
||||||
|
ps.fallAcc = progress * newStep;
|
||||||
|
ps.softDropping = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::move(PlayerSide side, int dx) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
tryMove(ps, dx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::rotate(PlayerSide side, int dir) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
Piece test = ps.cur;
|
||||||
|
test.rot = (test.rot + dir + 4) % 4;
|
||||||
|
// Simple wall kick: try in place, then left, then right
|
||||||
|
if (!collides(ps, test)) {
|
||||||
|
ps.cur = test; return;
|
||||||
|
}
|
||||||
|
test.x -= 1;
|
||||||
|
if (!collides(ps, test)) { ps.cur = test; return; }
|
||||||
|
test.x += 2;
|
||||||
|
if (!collides(ps, test)) { ps.cur = test; return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::hardDrop(PlayerSide side) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
|
||||||
|
hardDropCells.clear();
|
||||||
|
bool moved = false;
|
||||||
|
int dropped = 0;
|
||||||
|
while (tryMove(ps, 0, 1)) {
|
||||||
|
moved = true;
|
||||||
|
dropped++;
|
||||||
|
// Record path for potential effects
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||||
|
int px = ps.cur.x + cx;
|
||||||
|
int py = ps.cur.y + cy;
|
||||||
|
if (py >= 0) {
|
||||||
|
hardDropCells.push_back(SDL_Point{ px, py });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moved) {
|
||||||
|
_score += dropped; // 1 point per cell, matches single-player hard drop
|
||||||
|
hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS;
|
||||||
|
hardDropFxId++;
|
||||||
|
}
|
||||||
|
lock(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::holdCurrent(PlayerSide side) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
if (!ps.canHold) return;
|
||||||
|
if (ps.hold.type == PIECE_COUNT) {
|
||||||
|
ps.hold = ps.cur;
|
||||||
|
spawn(ps);
|
||||||
|
} else {
|
||||||
|
std::swap(ps.cur, ps.hold);
|
||||||
|
ps.cur.rot = 0;
|
||||||
|
ps.cur.x = columnMin(ps.side) + 3;
|
||||||
|
// Match single-player spawn height (I starts higher)
|
||||||
|
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||||
|
ps.pieceSeq++;
|
||||||
|
pieceSequence++;
|
||||||
|
}
|
||||||
|
ps.canHold = false;
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::tickGravity(double frameMs) {
|
||||||
|
if (gameOver) return;
|
||||||
|
|
||||||
|
auto stepPlayer = [&](PlayerState& ps) {
|
||||||
|
if (ps.toppedOut) return;
|
||||||
|
double step = ps.softDropping ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||||
|
ps.fallAcc += frameMs;
|
||||||
|
while (ps.fallAcc >= step) {
|
||||||
|
ps.fallAcc -= step;
|
||||||
|
if (!tryMove(ps, 0, 1)) {
|
||||||
|
ps.lockAcc += step;
|
||||||
|
if (ps.lockAcc >= LOCK_DELAY_MS) {
|
||||||
|
lock(ps);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Award soft drop points when actively holding down
|
||||||
|
if (ps.softDropping) {
|
||||||
|
_score += 1;
|
||||||
|
}
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stepPlayer(left);
|
||||||
|
stepPlayer(right);
|
||||||
|
|
||||||
|
updateRowStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::updateVisualEffects(double frameMs) {
|
||||||
|
if (hardDropShakeTimerMs > 0.0) {
|
||||||
|
hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double CoopGame::hardDropShakeStrength() const {
|
||||||
|
if (hardDropShakeTimerMs <= 0.0) return 0.0;
|
||||||
|
return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double CoopGame::gravityMsForLevel(int level) const {
|
||||||
|
return gravityMsForLevelInternal(level, gravityGlobalMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoopGame::cellFilled(const Piece& p, int cx, int cy) {
|
||||||
|
if (p.type >= PIECE_COUNT) return false;
|
||||||
|
const Shape& shape = SHAPES[p.type];
|
||||||
|
uint16_t mask = shape[p.rot % 4];
|
||||||
|
int bitIndex = cy * 4 + cx;
|
||||||
|
return (mask >> (15 - bitIndex)) & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::clearCompletedLines() {
|
||||||
|
if (completedLines.empty()) return;
|
||||||
|
clearLinesInternal();
|
||||||
|
completedLines.clear();
|
||||||
|
updateRowStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::refillBag(PlayerState& ps) {
|
||||||
|
ps.bag.clear();
|
||||||
|
ps.bag.reserve(PIECE_COUNT);
|
||||||
|
for (int i = 0; i < PIECE_COUNT; ++i) {
|
||||||
|
ps.bag.push_back(static_cast<PieceType>(i));
|
||||||
|
}
|
||||||
|
std::shuffle(ps.bag.begin(), ps.bag.end(), ps.rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
CoopGame::Piece CoopGame::drawFromBag(PlayerState& ps) {
|
||||||
|
if (ps.bag.empty()) {
|
||||||
|
refillBag(ps);
|
||||||
|
}
|
||||||
|
PieceType t = ps.bag.back();
|
||||||
|
ps.bag.pop_back();
|
||||||
|
Piece p{};
|
||||||
|
p.type = t;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::spawn(PlayerState& ps) {
|
||||||
|
if (ps.next.type == PIECE_COUNT) {
|
||||||
|
ps.next = drawFromBag(ps);
|
||||||
|
}
|
||||||
|
ps.cur = ps.next;
|
||||||
|
ps.cur.rot = 0;
|
||||||
|
ps.cur.x = columnMin(ps.side) + 3; // center within side
|
||||||
|
// Match single-player spawn height (I starts higher)
|
||||||
|
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||||
|
ps.next = drawFromBag(ps);
|
||||||
|
ps.canHold = true;
|
||||||
|
ps.softDropping = false;
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
ps.fallAcc = 0.0;
|
||||||
|
ps.pieceSeq++;
|
||||||
|
pieceSequence++;
|
||||||
|
if (collides(ps, ps.cur)) {
|
||||||
|
ps.toppedOut = true;
|
||||||
|
if (left.toppedOut && right.toppedOut) {
|
||||||
|
gameOver = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
||||||
|
int minX = columnMin(ps.side);
|
||||||
|
int maxX = columnMax(ps.side);
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(p, cx, cy)) continue;
|
||||||
|
int px = p.x + cx;
|
||||||
|
int py = p.y + cy;
|
||||||
|
if (px < minX || px > maxX) return true;
|
||||||
|
if (py >= ROWS) return true;
|
||||||
|
if (py < 0) continue; // allow spawn above board
|
||||||
|
int idx = py * COLS + px;
|
||||||
|
if (board[idx].occupied) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoopGame::tryMove(PlayerState& ps, int dx, int dy) {
|
||||||
|
Piece test = ps.cur;
|
||||||
|
test.x += dx;
|
||||||
|
test.y += dy;
|
||||||
|
if (collides(ps, test)) return false;
|
||||||
|
ps.cur = test;
|
||||||
|
if (dy > 0) {
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::lock(PlayerState& ps) {
|
||||||
|
// Write piece into the board
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||||
|
int px = ps.cur.x + cx;
|
||||||
|
int py = ps.cur.y + cy;
|
||||||
|
if (py < 0 || py >= ROWS) continue;
|
||||||
|
int idx = py * COLS + px;
|
||||||
|
board[idx].occupied = true;
|
||||||
|
board[idx].owner = ps.side;
|
||||||
|
board[idx].value = static_cast<int>(ps.cur.type) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Detect completed lines and apply rewards but DO NOT clear them here.
|
||||||
|
// Clearing is deferred to the visual `LineEffect` system (as in single-player)
|
||||||
|
findCompletedLines();
|
||||||
|
if (!completedLines.empty()) {
|
||||||
|
int cleared = static_cast<int>(completedLines.size());
|
||||||
|
applyLineClearRewards(cleared);
|
||||||
|
// Notify audio layer if present (matches single-player behavior)
|
||||||
|
if (soundCallback) soundCallback(cleared);
|
||||||
|
// Leave `completedLines` populated; `clearCompletedLines()` will be
|
||||||
|
// invoked by the state when the LineEffect finishes.
|
||||||
|
} else {
|
||||||
|
_currentCombo = 0;
|
||||||
|
}
|
||||||
|
spawn(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::findCompletedLines() {
|
||||||
|
completedLines.clear();
|
||||||
|
for (int r = 0; r < ROWS; ++r) {
|
||||||
|
bool leftFull = true;
|
||||||
|
bool rightFull = true;
|
||||||
|
for (int c = 0; c < COLS; ++c) {
|
||||||
|
const Cell& cell = board[r * COLS + c];
|
||||||
|
if (!cell.occupied) {
|
||||||
|
if (c < 10) leftFull = false; else rightFull = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowStates[r].leftFull = leftFull;
|
||||||
|
rowStates[r].rightFull = rightFull;
|
||||||
|
if (leftFull && rightFull) {
|
||||||
|
completedLines.push_back(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::applyLineClearRewards(int cleared) {
|
||||||
|
if (cleared <= 0) return;
|
||||||
|
|
||||||
|
// Base NES scoring scaled by shared level (level 0 => 1x multiplier)
|
||||||
|
int base = 0;
|
||||||
|
switch (cleared) {
|
||||||
|
case 1: base = 40; break;
|
||||||
|
case 2: base = 100; break;
|
||||||
|
case 3: base = 300; break;
|
||||||
|
case 4: base = 1200; break;
|
||||||
|
default: base = 0; break;
|
||||||
|
}
|
||||||
|
_score += base * (_level + 1);
|
||||||
|
|
||||||
|
_lines += cleared;
|
||||||
|
|
||||||
|
_currentCombo += 1;
|
||||||
|
if (_currentCombo > _maxCombo) _maxCombo = _currentCombo;
|
||||||
|
if (cleared > 1) {
|
||||||
|
_comboCount += 1;
|
||||||
|
}
|
||||||
|
if (cleared == 4) {
|
||||||
|
_tetrisesMade += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level progression mirrors single-player: threshold after (startLevel+1)*10 then every 10 lines
|
||||||
|
int targetLevel = startLevel;
|
||||||
|
int firstThreshold = (startLevel + 1) * 10;
|
||||||
|
if (_lines >= firstThreshold) {
|
||||||
|
targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10;
|
||||||
|
}
|
||||||
|
if (targetLevel > _level) {
|
||||||
|
_level = targetLevel;
|
||||||
|
gravityMs = gravityMsForLevel(_level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::clearLinesInternal() {
|
||||||
|
if (completedLines.empty()) return;
|
||||||
|
std::sort(completedLines.begin(), completedLines.end());
|
||||||
|
for (int idx = static_cast<int>(completedLines.size()) - 1; idx >= 0; --idx) {
|
||||||
|
int row = completedLines[idx];
|
||||||
|
for (int y = row; y > 0; --y) {
|
||||||
|
for (int x = 0; x < COLS; ++x) {
|
||||||
|
board[y * COLS + x] = board[(y - 1) * COLS + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int x = 0; x < COLS; ++x) {
|
||||||
|
board[x] = Cell{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound callback (optional) - invoked when lines are detected so audio can play
|
||||||
|
// (set via setSoundCallback)
|
||||||
|
// NOTE: defined inline in header as a std::function member; forward usage above
|
||||||
|
|
||||||
|
void CoopGame::updateRowStates() {
|
||||||
|
for (int r = 0; r < ROWS; ++r) {
|
||||||
|
bool leftFull = true;
|
||||||
|
bool rightFull = true;
|
||||||
|
for (int c = 0; c < COLS; ++c) {
|
||||||
|
const Cell& cell = board[r * COLS + c];
|
||||||
|
if (!cell.occupied) {
|
||||||
|
if (c < 10) leftFull = false; else rightFull = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowStates[r].leftFull = leftFull;
|
||||||
|
rowStates[r].rightFull = rightFull;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/gameplay/coop/CoopGame.h
Normal file
143
src/gameplay/coop/CoopGame.h
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <optional>
|
||||||
|
#include <random>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include "../core/Game.h" // For PieceType enums and gravity table helpers
|
||||||
|
|
||||||
|
// Cooperative two-player session with a shared 20-column board split into halves.
|
||||||
|
// This is an early scaffold: rules and rendering hooks will be iterated in follow-up passes.
|
||||||
|
class CoopGame {
|
||||||
|
public:
|
||||||
|
enum class PlayerSide { Left, Right };
|
||||||
|
|
||||||
|
static constexpr int COLS = 20;
|
||||||
|
static constexpr int ROWS = Game::ROWS;
|
||||||
|
static constexpr int TILE = Game::TILE;
|
||||||
|
|
||||||
|
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{0}; int y{-2}; };
|
||||||
|
|
||||||
|
struct Cell {
|
||||||
|
int value{0}; // 0 empty else color index (1..7)
|
||||||
|
PlayerSide owner{PlayerSide::Left};
|
||||||
|
bool occupied{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RowHalfState {
|
||||||
|
bool leftFull{false};
|
||||||
|
bool rightFull{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PlayerState {
|
||||||
|
PlayerSide side{PlayerSide::Left};
|
||||||
|
Piece cur{};
|
||||||
|
Piece hold{};
|
||||||
|
Piece next{};
|
||||||
|
uint64_t pieceSeq{0};
|
||||||
|
bool canHold{true};
|
||||||
|
bool softDropping{false};
|
||||||
|
bool toppedOut{false};
|
||||||
|
double fallAcc{0.0};
|
||||||
|
double lockAcc{0.0};
|
||||||
|
std::vector<PieceType> bag{}; // 7-bag queue
|
||||||
|
std::mt19937 rng{ std::random_device{}() };
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit CoopGame(int startLevel = 0);
|
||||||
|
using SoundCallback = std::function<void(int)>;
|
||||||
|
void setSoundCallback(SoundCallback cb) { soundCallback = cb; }
|
||||||
|
|
||||||
|
void reset(int startLevel = 0);
|
||||||
|
void tickGravity(double frameMs);
|
||||||
|
void updateVisualEffects(double frameMs);
|
||||||
|
|
||||||
|
// Per-player inputs -----------------------------------------------------
|
||||||
|
void setSoftDropping(PlayerSide side, bool on);
|
||||||
|
void move(PlayerSide side, int dx);
|
||||||
|
void rotate(PlayerSide side, int dir); // +1 cw, -1 ccw
|
||||||
|
void hardDrop(PlayerSide side);
|
||||||
|
void holdCurrent(PlayerSide side);
|
||||||
|
|
||||||
|
// Accessors -------------------------------------------------------------
|
||||||
|
const std::array<Cell, COLS * ROWS>& boardRef() const { return board; }
|
||||||
|
const Piece& current(PlayerSide s) const { return player(s).cur; }
|
||||||
|
const Piece& next(PlayerSide s) const { return player(s).next; }
|
||||||
|
const Piece& held(PlayerSide s) const { return player(s).hold; }
|
||||||
|
bool canHold(PlayerSide s) const { return player(s).canHold; }
|
||||||
|
bool isGameOver() const { return gameOver; }
|
||||||
|
int score() const { return _score; }
|
||||||
|
int lines() const { return _lines; }
|
||||||
|
int level() const { return _level; }
|
||||||
|
int comboCount() const { return _comboCount; }
|
||||||
|
int maxCombo() const { return _maxCombo; }
|
||||||
|
int tetrisesMade() const { return _tetrisesMade; }
|
||||||
|
double getGravityMs() const { return gravityMs; }
|
||||||
|
double getFallAccumulator(PlayerSide s) const { return player(s).fallAcc; }
|
||||||
|
bool isSoftDropping(PlayerSide s) const { return player(s).softDropping; }
|
||||||
|
uint64_t currentPieceSequence(PlayerSide s) const { return player(s).pieceSeq; }
|
||||||
|
const std::vector<int>& getCompletedLines() const { return completedLines; }
|
||||||
|
bool hasCompletedLines() const { return !completedLines.empty(); }
|
||||||
|
void clearCompletedLines();
|
||||||
|
const std::array<RowHalfState, ROWS>& rowHalfStates() const { return rowStates; }
|
||||||
|
|
||||||
|
// Simple visual-effect compatibility (stubbed for now)
|
||||||
|
bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; }
|
||||||
|
double hardDropShakeStrength() const;
|
||||||
|
const std::vector<SDL_Point>& getHardDropCells() const { return hardDropCells; }
|
||||||
|
uint32_t getHardDropFxId() const { return hardDropFxId; }
|
||||||
|
|
||||||
|
static bool cellFilled(const Piece& p, int cx, int cy);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr double LOCK_DELAY_MS = 500.0;
|
||||||
|
|
||||||
|
std::array<Cell, COLS * ROWS> board{};
|
||||||
|
std::array<RowHalfState, ROWS> rowStates{};
|
||||||
|
PlayerState left{};
|
||||||
|
PlayerState right{ PlayerSide::Right };
|
||||||
|
|
||||||
|
int _score{0};
|
||||||
|
int _lines{0};
|
||||||
|
int _level{1};
|
||||||
|
int _tetrisesMade{0};
|
||||||
|
int _currentCombo{0};
|
||||||
|
int _maxCombo{0};
|
||||||
|
int _comboCount{0};
|
||||||
|
int startLevel{0};
|
||||||
|
double gravityMs{800.0};
|
||||||
|
double gravityGlobalMultiplier{1.0};
|
||||||
|
bool gameOver{false};
|
||||||
|
|
||||||
|
std::vector<int> completedLines;
|
||||||
|
|
||||||
|
// Impact FX
|
||||||
|
double hardDropShakeTimerMs{0.0};
|
||||||
|
static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0;
|
||||||
|
std::vector<SDL_Point> hardDropCells;
|
||||||
|
uint32_t hardDropFxId{0};
|
||||||
|
uint64_t pieceSequence{0};
|
||||||
|
SoundCallback soundCallback;
|
||||||
|
|
||||||
|
// Helpers ---------------------------------------------------------------
|
||||||
|
PlayerState& player(PlayerSide s) { return s == PlayerSide::Left ? left : right; }
|
||||||
|
const PlayerState& player(PlayerSide s) const { return s == PlayerSide::Left ? left : right; }
|
||||||
|
|
||||||
|
void refillBag(PlayerState& ps);
|
||||||
|
Piece drawFromBag(PlayerState& ps);
|
||||||
|
void spawn(PlayerState& ps);
|
||||||
|
bool collides(const PlayerState& ps, const Piece& p) const;
|
||||||
|
bool tryMove(PlayerState& ps, int dx, int dy);
|
||||||
|
void lock(PlayerState& ps);
|
||||||
|
void findCompletedLines();
|
||||||
|
void clearLinesInternal();
|
||||||
|
void updateRowStates();
|
||||||
|
void applyLineClearRewards(int cleared);
|
||||||
|
double gravityMsForLevel(int level) const;
|
||||||
|
int columnMin(PlayerSide s) const { return s == PlayerSide::Left ? 0 : 10; }
|
||||||
|
int columnMax(PlayerSide s) const { return s == PlayerSide::Left ? 9 : 19; }
|
||||||
|
};
|
||||||
@ -15,7 +15,7 @@ enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
|
|||||||
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
||||||
|
|
||||||
// Game runtime mode
|
// Game runtime mode
|
||||||
enum class GameMode { Endless, Challenge };
|
enum class GameMode { Endless, Cooperate, Challenge };
|
||||||
|
|
||||||
// Special obstacle blocks used by Challenge mode
|
// Special obstacle blocks used by Challenge mode
|
||||||
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
#include "GameRenderer.h"
|
#include "GameRenderer.h"
|
||||||
#include "../../gameplay/core/Game.h"
|
#include "../../gameplay/core/Game.h"
|
||||||
|
#include "../../gameplay/coop/CoopGame.h"
|
||||||
|
#include "../../app/Fireworks.h"
|
||||||
#include "../ui/Font.h"
|
#include "../ui/Font.h"
|
||||||
#include "../../gameplay/effects/LineEffect.h"
|
#include "../../gameplay/effects/LineEffect.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@ -693,6 +695,11 @@ void GameRenderer::renderPlayingState(
|
|||||||
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||||
auto completedLines = game->getCompletedLines();
|
auto completedLines = game->getCompletedLines();
|
||||||
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
||||||
|
// Trigger fireworks visually for a 4-line clear (TETRIS)
|
||||||
|
if (completedLines.size() == 4) {
|
||||||
|
// spawn near center of grid
|
||||||
|
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw game grid border
|
// Draw game grid border
|
||||||
@ -1356,6 +1363,24 @@ void GameRenderer::renderPlayingState(
|
|||||||
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
|
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug: log single-player smoothing/fall values when enabled
|
||||||
|
if (Settings::instance().isDebugEnabled()) {
|
||||||
|
float sp_targetX = static_cast<float>(game->current().x);
|
||||||
|
double sp_gravityMs = game->getGravityMs();
|
||||||
|
double sp_fallAcc = game->getFallAccumulator();
|
||||||
|
int sp_soft = game->isSoftDropping() ? 1 : 0;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
|
||||||
|
(unsigned long long)s_activePieceSmooth.sequence,
|
||||||
|
s_activePieceSmooth.visualX,
|
||||||
|
sp_targetX,
|
||||||
|
activePiecePixelOffsetX,
|
||||||
|
activePiecePixelOffsetY,
|
||||||
|
sp_gravityMs,
|
||||||
|
sp_fallAcc,
|
||||||
|
sp_soft
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw ghost piece (where current piece will land)
|
// Draw ghost piece (where current piece will land)
|
||||||
if (allowActivePieceRender) {
|
if (allowActivePieceRender) {
|
||||||
Game::Piece ghostPiece = game->current();
|
Game::Piece ghostPiece = game->current();
|
||||||
@ -1806,6 +1831,362 @@ void GameRenderer::renderPlayingState(
|
|||||||
// Exit popup logic moved to renderExitPopup
|
// Exit popup logic moved to renderExitPopup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameRenderer::renderCoopPlayingState(
|
||||||
|
SDL_Renderer* renderer,
|
||||||
|
CoopGame* game,
|
||||||
|
FontAtlas* pixelFont,
|
||||||
|
LineEffect* lineEffect,
|
||||||
|
SDL_Texture* blocksTex,
|
||||||
|
SDL_Texture* statisticsPanelTex,
|
||||||
|
SDL_Texture* scorePanelTex,
|
||||||
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
|
float logicalW,
|
||||||
|
float logicalH,
|
||||||
|
float logicalScale,
|
||||||
|
float winW,
|
||||||
|
float winH
|
||||||
|
) {
|
||||||
|
if (!renderer || !game || !pixelFont) return;
|
||||||
|
|
||||||
|
static Uint32 s_lastCoopTick = SDL_GetTicks();
|
||||||
|
Uint32 nowTicks = SDL_GetTicks();
|
||||||
|
float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
|
||||||
|
s_lastCoopTick = nowTicks;
|
||||||
|
if (deltaMs < 0.0f || deltaMs > 100.0f) {
|
||||||
|
deltaMs = 16.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
|
||||||
|
struct SmoothState { bool initialized{false}; uint64_t seq{0}; float visualX{0.0f}; float visualY{0.0f}; };
|
||||||
|
static SmoothState s_leftSmooth{};
|
||||||
|
static SmoothState s_rightSmooth{};
|
||||||
|
struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; };
|
||||||
|
static SpawnFadeState s_leftSpawnFade{};
|
||||||
|
static SpawnFadeState s_rightSpawnFade{};
|
||||||
|
|
||||||
|
// Layout constants (reuse single-player feel but sized for 20 cols)
|
||||||
|
const float MIN_MARGIN = 40.0f;
|
||||||
|
const float TOP_MARGIN = 60.0f;
|
||||||
|
const float PANEL_WIDTH = 180.0f;
|
||||||
|
const float PANEL_SPACING = 30.0f;
|
||||||
|
const float NEXT_PANEL_HEIGHT = 120.0f;
|
||||||
|
const float BOTTOM_MARGIN = 60.0f;
|
||||||
|
|
||||||
|
// Content offset (centered logical viewport inside window)
|
||||||
|
float contentScale = logicalScale;
|
||||||
|
float contentW = logicalW * contentScale;
|
||||||
|
float contentH = logicalH * contentScale;
|
||||||
|
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
|
||||||
|
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
|
||||||
|
|
||||||
|
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
|
||||||
|
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
|
||||||
|
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
|
||||||
|
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PANEL_HEIGHT;
|
||||||
|
|
||||||
|
const float maxBlockSizeW = availableWidth / CoopGame::COLS;
|
||||||
|
const float maxBlockSizeH = availableHeight / CoopGame::ROWS;
|
||||||
|
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
|
||||||
|
const float finalBlockSize = std::max(16.0f, std::min(BLOCK_SIZE, 36.0f));
|
||||||
|
|
||||||
|
const float GRID_W = CoopGame::COLS * finalBlockSize;
|
||||||
|
const float GRID_H = CoopGame::ROWS * finalBlockSize;
|
||||||
|
|
||||||
|
const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
|
||||||
|
const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
|
||||||
|
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
|
||||||
|
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
|
||||||
|
|
||||||
|
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
|
||||||
|
const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
|
||||||
|
|
||||||
|
const float statsX = layoutStartX + contentOffsetX;
|
||||||
|
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
|
||||||
|
const float gridY = contentStartY + NEXT_PANEL_HEIGHT + contentOffsetY;
|
||||||
|
|
||||||
|
const float statsY = gridY;
|
||||||
|
const float statsW = PANEL_WIDTH;
|
||||||
|
const float statsH = GRID_H;
|
||||||
|
|
||||||
|
// Shared score panel (reuse existing art)
|
||||||
|
SDL_FRect scorePanelBg{ statsX - 20.0f, gridY - 26.0f, statsW + 40.0f, GRID_H + 52.0f };
|
||||||
|
if (statisticsPanelTex) {
|
||||||
|
SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &scorePanelBg);
|
||||||
|
} else if (scorePanelTex) {
|
||||||
|
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &scorePanelBg);
|
||||||
|
} else {
|
||||||
|
drawRectWithOffset(scorePanelBg.x - contentOffsetX, scorePanelBg.y - contentOffsetY, scorePanelBg.w, scorePanelBg.h, SDL_Color{12,18,32,205});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle line clearing effects (defer to LineEffect like single-player)
|
||||||
|
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||||
|
auto completedLines = game->getCompletedLines();
|
||||||
|
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
||||||
|
if (completedLines.size() == 4) {
|
||||||
|
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid backdrop and border
|
||||||
|
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
|
||||||
|
drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
|
||||||
|
|
||||||
|
// Divider line between halves (between columns 9 and 10)
|
||||||
|
float dividerX = gridX + finalBlockSize * 10.0f;
|
||||||
|
SDL_SetRenderDrawColor(renderer, 180, 210, 255, 235);
|
||||||
|
SDL_FRect divider{ dividerX - 2.0f, gridY, 4.0f, GRID_H };
|
||||||
|
SDL_RenderFillRect(renderer, ÷r);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 40, 80, 150, 140);
|
||||||
|
SDL_FRect dividerGlow{ dividerX - 4.0f, gridY, 8.0f, GRID_H };
|
||||||
|
SDL_RenderFillRect(renderer, ÷rGlow);
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
|
||||||
|
for (int x = 1; x < CoopGame::COLS; ++x) {
|
||||||
|
float lineX = gridX + x * finalBlockSize;
|
||||||
|
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
|
||||||
|
}
|
||||||
|
for (int y = 1; y < CoopGame::ROWS; ++y) {
|
||||||
|
float lineY = gridY + y * finalBlockSize;
|
||||||
|
SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half-row feedback: lightly tint rows where one side is filled, brighter where both are pending clear
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
const auto& rowStates = game->rowHalfStates();
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
const auto& rs = rowStates[y];
|
||||||
|
float rowY = gridY + y * finalBlockSize;
|
||||||
|
|
||||||
|
if (rs.leftFull && rs.rightFull) {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45);
|
||||||
|
SDL_FRect fr{gridX, rowY, GRID_W, finalBlockSize};
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
} else if (rs.leftFull ^ rs.rightFull) {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35);
|
||||||
|
float w = GRID_W * 0.5f;
|
||||||
|
float x = rs.leftFull ? gridX : gridX + w;
|
||||||
|
SDL_FRect fr{x, rowY, w, finalBlockSize};
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
|
||||||
|
// Draw settled blocks
|
||||||
|
const auto& board = game->boardRef();
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
for (int x = 0; x < CoopGame::COLS; ++x) {
|
||||||
|
const auto& cell = board[y * CoopGame::COLS + x];
|
||||||
|
if (!cell.occupied || cell.value <= 0) continue;
|
||||||
|
float px = gridX + x * finalBlockSize;
|
||||||
|
float py = gridY + y * finalBlockSize;
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active pieces (per-side smoothing)
|
||||||
|
auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) {
|
||||||
|
float offsetX = 0.0f;
|
||||||
|
float offsetY = 0.0f;
|
||||||
|
|
||||||
|
if (smoothScrollEnabled) {
|
||||||
|
const uint64_t seq = game->currentPieceSequence(side);
|
||||||
|
const float targetX = static_cast<float>(game->current(side).x);
|
||||||
|
if (!ss.initialized || ss.seq != seq) {
|
||||||
|
ss.initialized = true;
|
||||||
|
ss.seq = seq;
|
||||||
|
ss.visualX = targetX;
|
||||||
|
// Trigger a short spawn fade so the newly spawned piece visually
|
||||||
|
// fades into the first visible row (like classic mode).
|
||||||
|
SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade;
|
||||||
|
sf.active = true;
|
||||||
|
sf.startTick = nowTicks;
|
||||||
|
sf.durationMs = 200.0f;
|
||||||
|
sf.seq = seq;
|
||||||
|
sf.piece = game->current(side);
|
||||||
|
sf.tileSize = finalBlockSize;
|
||||||
|
// Target to first visible row (row 0)
|
||||||
|
sf.targetX = gridX + static_cast<float>(sf.piece.x) * finalBlockSize;
|
||||||
|
sf.targetY = gridY + 0.0f * finalBlockSize;
|
||||||
|
} else {
|
||||||
|
// Reuse exact horizontal smoothing from single-player
|
||||||
|
constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
|
||||||
|
const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
|
||||||
|
ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor);
|
||||||
|
}
|
||||||
|
offsetX = (ss.visualX - targetX) * finalBlockSize;
|
||||||
|
|
||||||
|
// Reuse exact single-player fall offset computation (per-side getters)
|
||||||
|
double gravityMs = game->getGravityMs();
|
||||||
|
if (gravityMs > 0.0) {
|
||||||
|
double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||||
|
double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs);
|
||||||
|
float progress = static_cast<float>(accumulator / effectiveMs);
|
||||||
|
progress = std::clamp(progress, 0.0f, 1.0f);
|
||||||
|
offsetY = progress * finalBlockSize;
|
||||||
|
|
||||||
|
// Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player)
|
||||||
|
const auto& boardRef = game->boardRef();
|
||||||
|
const CoopGame::Piece& piece = game->current(side);
|
||||||
|
float maxAllowed = finalBlockSize;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||||
|
int gx = piece.x + cx;
|
||||||
|
int gy = piece.y + cy;
|
||||||
|
if (gx < 0 || gx >= CoopGame::COLS) continue;
|
||||||
|
int testY = gy + 1;
|
||||||
|
int emptyRows = 0;
|
||||||
|
if (testY < 0) {
|
||||||
|
emptyRows -= testY;
|
||||||
|
testY = 0;
|
||||||
|
}
|
||||||
|
while (testY >= 0 && testY < CoopGame::ROWS) {
|
||||||
|
if (boardRef[testY * CoopGame::COLS + gx].occupied) break;
|
||||||
|
++emptyRows;
|
||||||
|
++testY;
|
||||||
|
}
|
||||||
|
float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
|
||||||
|
maxAllowed = std::min(maxAllowed, cellLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offsetY = std::min(offsetY, maxAllowed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ss.initialized = true;
|
||||||
|
ss.seq = game->currentPieceSequence(side);
|
||||||
|
ss.visualX = static_cast<float>(game->current(side).x);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::instance().isDebugEnabled()) {
|
||||||
|
float dbg_targetX = static_cast<float>(game->current(side).x);
|
||||||
|
double gMsDbg = game->getGravityMs();
|
||||||
|
double accDbg = game->getFallAccumulator(side);
|
||||||
|
int softDbg = game->isSoftDropping(side) ? 1 : 0;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
|
||||||
|
(side == CoopGame::PlayerSide::Left) ? "L" : "R",
|
||||||
|
(unsigned long long)ss.seq,
|
||||||
|
ss.visualX,
|
||||||
|
dbg_targetX,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
gMsDbg,
|
||||||
|
accDbg,
|
||||||
|
softDbg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::pair<float, float>{ offsetX, offsetY };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw any active spawn fades (alpha ramp into first row). Draw before
|
||||||
|
// the regular active pieces; while the spawn fade is active the piece's
|
||||||
|
// real position is above the grid and will not be drawn by drawPiece.
|
||||||
|
auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf) {
|
||||||
|
if (!sf.active) return;
|
||||||
|
Uint32 now = SDL_GetTicks();
|
||||||
|
float elapsed = static_cast<float>(now - sf.startTick);
|
||||||
|
float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
|
||||||
|
Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
|
||||||
|
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha);
|
||||||
|
// Draw piece at target (first row)
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(sf.piece, cx, cy)) continue;
|
||||||
|
float px = sf.targetX + static_cast<float>(cx) * sf.tileSize;
|
||||||
|
float py = sf.targetY + static_cast<float>(cy) * sf.tileSize;
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, sf.piece.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
|
||||||
|
if (t >= 1.0f) sf.active = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto drawPiece = [&](const CoopGame::Piece& p, CoopGame::PlayerSide side, const std::pair<float,float>& offsets) {
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(p, cx, cy)) continue;
|
||||||
|
int pxIdx = p.x + cx;
|
||||||
|
int pyIdx = p.y + cy;
|
||||||
|
if (pyIdx < 0) continue; // don't draw parts above the visible grid
|
||||||
|
float px = gridX + (float)pxIdx * finalBlockSize + offsets.first;
|
||||||
|
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth);
|
||||||
|
const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth);
|
||||||
|
// Draw transient spawn fades (if active) into the first visible row
|
||||||
|
drawSpawnFadeIfActive(s_leftSpawnFade);
|
||||||
|
drawSpawnFadeIfActive(s_rightSpawnFade);
|
||||||
|
|
||||||
|
// If a spawn fade is active for a side and matches the current piece
|
||||||
|
// sequence, only draw the fade visual and skip the regular piece draw
|
||||||
|
// to avoid a double-draw that appears as a jump when falling starts.
|
||||||
|
if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) {
|
||||||
|
drawPiece(game->current(CoopGame::PlayerSide::Left), CoopGame::PlayerSide::Left, leftOffsets);
|
||||||
|
}
|
||||||
|
if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) {
|
||||||
|
drawPiece(game->current(CoopGame::PlayerSide::Right), CoopGame::PlayerSide::Right, rightOffsets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next panels (two)
|
||||||
|
const float nextPanelPad = 12.0f;
|
||||||
|
const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f;
|
||||||
|
const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
|
||||||
|
float nextLeftX = gridX + finalBlockSize;
|
||||||
|
float nextRightX = gridX + GRID_W - finalBlockSize - nextPanelW;
|
||||||
|
float nextY = contentStartY + contentOffsetY;
|
||||||
|
|
||||||
|
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
|
||||||
|
SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
|
||||||
|
if (nextPanelTex) {
|
||||||
|
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel);
|
||||||
|
} else {
|
||||||
|
drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
|
||||||
|
}
|
||||||
|
// Center piece inside panel
|
||||||
|
int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||||
|
minCx = std::min(minCx, cx);
|
||||||
|
minCy = std::min(minCy, cy);
|
||||||
|
maxCx = std::max(maxCx, cx);
|
||||||
|
maxCy = std::max(maxCy, cy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxCx >= minCx && maxCy >= minCy) {
|
||||||
|
float tile = finalBlockSize * 0.8f;
|
||||||
|
float pieceW = (maxCx - minCx + 1) * tile;
|
||||||
|
float pieceH = (maxCy - minCy + 1) * tile;
|
||||||
|
float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile;
|
||||||
|
float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||||
|
float px = startX + cx * tile;
|
||||||
|
float py = startY + cy * tile;
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left));
|
||||||
|
drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right));
|
||||||
|
|
||||||
|
// Simple shared score text
|
||||||
|
char buf[128];
|
||||||
|
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", game->score(), game->lines(), game->level());
|
||||||
|
pixelFont->draw(renderer, gridX + GRID_W * 0.5f - 140.0f, gridY + GRID_H + 24.0f, buf, 1.2f, SDL_Color{220, 230, 255, 255});
|
||||||
|
}
|
||||||
|
|
||||||
void GameRenderer::renderExitPopup(
|
void GameRenderer::renderExitPopup(
|
||||||
SDL_Renderer* renderer,
|
SDL_Renderer* renderer,
|
||||||
FontAtlas* pixelFont,
|
FontAtlas* pixelFont,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include "../../gameplay/core/Game.h"
|
#include "../../gameplay/core/Game.h"
|
||||||
|
#include "../../gameplay/coop/CoopGame.h"
|
||||||
|
|
||||||
// Forward declarations
|
// Forward declarations
|
||||||
class FontAtlas;
|
class FontAtlas;
|
||||||
@ -61,6 +62,23 @@ public:
|
|||||||
int selectedButton
|
int selectedButton
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static void renderCoopPlayingState(
|
||||||
|
SDL_Renderer* renderer,
|
||||||
|
CoopGame* game,
|
||||||
|
FontAtlas* pixelFont,
|
||||||
|
LineEffect* lineEffect,
|
||||||
|
SDL_Texture* blocksTex,
|
||||||
|
SDL_Texture* statisticsPanelTex,
|
||||||
|
SDL_Texture* scorePanelTex,
|
||||||
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
|
float logicalW,
|
||||||
|
float logicalH,
|
||||||
|
float logicalScale,
|
||||||
|
float winW,
|
||||||
|
float winH
|
||||||
|
);
|
||||||
|
|
||||||
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
||||||
// calling from non-member helper functions (e.g. visual effects) that cannot
|
// calling from non-member helper functions (e.g. visual effects) that cannot
|
||||||
// access private class members.
|
// access private class members.
|
||||||
|
|||||||
@ -442,7 +442,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
case SDL_SCANCODE_LEFT:
|
case SDL_SCANCODE_LEFT:
|
||||||
case SDL_SCANCODE_UP:
|
case SDL_SCANCODE_UP:
|
||||||
{
|
{
|
||||||
const int total = 7;
|
const int total = MENU_BTN_COUNT;
|
||||||
selectedButton = (selectedButton + total - 1) % total;
|
selectedButton = (selectedButton + total - 1) % total;
|
||||||
// brief bright flash on navigation
|
// brief bright flash on navigation
|
||||||
buttonFlash = 1.0;
|
buttonFlash = 1.0;
|
||||||
@ -451,7 +451,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
case SDL_SCANCODE_RIGHT:
|
case SDL_SCANCODE_RIGHT:
|
||||||
case SDL_SCANCODE_DOWN:
|
case SDL_SCANCODE_DOWN:
|
||||||
{
|
{
|
||||||
const int total = 7;
|
const int total = MENU_BTN_COUNT;
|
||||||
selectedButton = (selectedButton + 1) % total;
|
selectedButton = (selectedButton + 1) % total;
|
||||||
// brief bright flash on navigation
|
// brief bright flash on navigation
|
||||||
buttonFlash = 1.0;
|
buttonFlash = 1.0;
|
||||||
@ -470,6 +470,17 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
triggerPlay();
|
triggerPlay();
|
||||||
break;
|
break;
|
||||||
case 1:
|
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
|
// Start challenge run at level 1
|
||||||
if (ctx.game) {
|
if (ctx.game) {
|
||||||
ctx.game->setMode(GameMode::Challenge);
|
ctx.game->setMode(GameMode::Challenge);
|
||||||
@ -480,7 +491,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
triggerPlay();
|
triggerPlay();
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 3:
|
||||||
// Toggle inline level selector HUD (show/hide)
|
// Toggle inline level selector HUD (show/hide)
|
||||||
if (!levelPanelVisible && !levelPanelAnimating) {
|
if (!levelPanelVisible && !levelPanelAnimating) {
|
||||||
levelPanelAnimating = true;
|
levelPanelAnimating = true;
|
||||||
@ -492,7 +503,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
levelDirection = -1; // hide
|
levelDirection = -1; // hide
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 4:
|
||||||
// Toggle the options panel with an animated slide-in/out.
|
// Toggle the options panel with an animated slide-in/out.
|
||||||
if (!optionsVisible && !optionsAnimating) {
|
if (!optionsVisible && !optionsAnimating) {
|
||||||
optionsAnimating = true;
|
optionsAnimating = true;
|
||||||
@ -502,7 +513,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
optionsDirection = -1; // hide
|
optionsDirection = -1; // hide
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 5:
|
||||||
// Toggle the inline HELP HUD (show/hide)
|
// Toggle the inline HELP HUD (show/hide)
|
||||||
if (!helpPanelVisible && !helpPanelAnimating) {
|
if (!helpPanelVisible && !helpPanelAnimating) {
|
||||||
helpPanelAnimating = true;
|
helpPanelAnimating = true;
|
||||||
@ -513,7 +524,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
helpDirection = -1; // hide
|
helpDirection = -1; // hide
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 6:
|
||||||
// Toggle the inline ABOUT HUD (show/hide)
|
// Toggle the inline ABOUT HUD (show/hide)
|
||||||
if (!aboutPanelVisible && !aboutPanelAnimating) {
|
if (!aboutPanelVisible && !aboutPanelAnimating) {
|
||||||
aboutPanelAnimating = true;
|
aboutPanelAnimating = true;
|
||||||
@ -523,7 +534,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
aboutDirection = -1;
|
aboutDirection = -1;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 7:
|
||||||
// Show the inline exit HUD
|
// Show the inline exit HUD
|
||||||
if (!exitPanelVisible && !exitPanelAnimating) {
|
if (!exitPanelVisible && !exitPanelAnimating) {
|
||||||
exitPanelAnimating = true;
|
exitPanelAnimating = true;
|
||||||
|
|||||||
@ -21,7 +21,7 @@ public:
|
|||||||
void showAboutPanel(bool show);
|
void showAboutPanel(bool show);
|
||||||
|
|
||||||
private:
|
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)
|
// Button icons (optional - will use text if nullptr)
|
||||||
SDL_Texture* playIcon = nullptr;
|
SDL_Texture* playIcon = nullptr;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
#include "PlayingState.h"
|
#include "PlayingState.h"
|
||||||
#include "../core/state/StateManager.h"
|
#include "../core/state/StateManager.h"
|
||||||
#include "../gameplay/core/Game.h"
|
#include "../gameplay/core/Game.h"
|
||||||
|
#include "../gameplay/coop/CoopGame.h"
|
||||||
#include "../gameplay/effects/LineEffect.h"
|
#include "../gameplay/effects/LineEffect.h"
|
||||||
#include "../persistence/Scores.h"
|
#include "../persistence/Scores.h"
|
||||||
#include "../audio/Audio.h"
|
#include "../audio/Audio.h"
|
||||||
@ -18,12 +19,15 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
|
|||||||
|
|
||||||
void PlayingState::onEnter() {
|
void PlayingState::onEnter() {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
|
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) {
|
||||||
if (ctx.game->getMode() == GameMode::Endless) {
|
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
|
||||||
if (ctx.startLevelSelection) {
|
if (ctx.startLevelSelection) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||||
ctx.game->reset(*ctx.startLevelSelection);
|
ctx.game->reset(*ctx.startLevelSelection);
|
||||||
|
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
||||||
|
ctx.coopGame->reset(*ctx.startLevelSelection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
||||||
@ -45,124 +49,164 @@ void PlayingState::onExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PlayingState::handleEvent(const SDL_Event& e) {
|
void PlayingState::handleEvent(const SDL_Event& e) {
|
||||||
|
if (!ctx.game) return;
|
||||||
|
|
||||||
// If a transport animation is active, ignore gameplay input entirely.
|
// If a transport animation is active, ignore gameplay input entirely.
|
||||||
if (GameRenderer::isTransportActive()) {
|
if (GameRenderer::isTransportActive()) {
|
||||||
return;
|
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) {
|
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||||
if (ctx.exitPopupSelectedButton) {
|
|
||||||
*ctx.exitPopupSelectedButton = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
auto getExitSelection = [&]() -> int {
|
auto setExitSelection = [&](int idx) {
|
||||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
if (ctx.exitPopupSelectedButton) {
|
||||||
};
|
*ctx.exitPopupSelectedButton = idx;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auto getExitSelection = [&]() -> int {
|
||||||
|
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||||
|
};
|
||||||
|
|
||||||
// Pause toggle (P)
|
if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) {
|
||||||
if (e.key.scancode == SDL_SCANCODE_P) {
|
return;
|
||||||
bool paused = ctx.game->isPaused();
|
}
|
||||||
ctx.game->setPaused(!paused);
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If exit-confirm popup is visible, handle shortcuts here
|
// Activate selected button with Enter or Space
|
||||||
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||||
// Navigate between YES (0) and NO (1) buttons
|
const bool confirmExit = (getExitSelection() == 0);
|
||||||
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
*ctx.showExitConfirmPopup = false;
|
||||||
setExitSelection(0);
|
if (confirmExit) {
|
||||||
return;
|
// YES - Reset game and return to menu
|
||||||
}
|
if (ctx.startLevelSelection) {
|
||||||
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
ctx.game->reset(*ctx.startLevelSelection);
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
// NO - Just close popup and resume
|
ctx.game->reset(0);
|
||||||
ctx.game->setPaused(false);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Cancel with Esc (same as NO)
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
|
||||||
*ctx.showExitConfirmPopup = false;
|
|
||||||
ctx.game->setPaused(false);
|
ctx.game->setPaused(false);
|
||||||
setExitSelection(1);
|
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
||||||
return;
|
} else {
|
||||||
|
// NO - Just close popup and resume
|
||||||
|
ctx.game->setPaused(false);
|
||||||
}
|
}
|
||||||
// While modal is open, suppress other gameplay keys
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Cancel with Esc (same as NO)
|
||||||
// ESC key - open confirmation popup
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||||
if (ctx.showExitConfirmPopup) {
|
*ctx.showExitConfirmPopup = false;
|
||||||
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.game->setPaused(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tetris controls (only when not paused)
|
// Player 2 (right): arrow keys move via DAS; rotations/hold/hard-drop here
|
||||||
if (!ctx.game->isPaused()) {
|
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
// Hold / swap current piece (H)
|
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
if (e.key.scancode == SDL_SCANCODE_H) {
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
|
||||||
ctx.game->holdCurrent();
|
return;
|
||||||
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)
|
// Rotation (still event-based for precise timing)
|
||||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
// Use user setting to determine whether UP rotates clockwise
|
// Use user setting to determine whether UP rotates clockwise
|
||||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
ctx.game->rotate(upIsCW ? 1 : -1);
|
ctx.game->rotate(upIsCW ? 1 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.scancode == SDL_SCANCODE_X) {
|
if (e.key.scancode == SDL_SCANCODE_X) {
|
||||||
// Toggle the mapping so UP will rotate in the opposite direction
|
// Toggle the mapping so UP will rotate in the opposite direction
|
||||||
bool current = Settings::instance().isUpRotateClockwise();
|
bool current = Settings::instance().isUpRotateClockwise();
|
||||||
Settings::instance().setUpRotateClockwise(!current);
|
Settings::instance().setUpRotateClockwise(!current);
|
||||||
Settings::instance().save();
|
Settings::instance().save();
|
||||||
// Play a subtle feedback sound if available
|
// Play a subtle feedback sound if available
|
||||||
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard drop (space)
|
// Hard drop (space)
|
||||||
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
ctx.game->hardDrop();
|
ctx.game->hardDrop();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,7 +216,21 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
|
|
||||||
void PlayingState::update(double frameMs) {
|
void PlayingState::update(double frameMs) {
|
||||||
if (!ctx.game) return;
|
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);
|
ctx.game->updateVisualEffects(frameMs);
|
||||||
// If a transport animation is active, pause gameplay updates and ignore inputs
|
// If a transport animation is active, pause gameplay updates and ignore inputs
|
||||||
if (GameRenderer::isTransportActive()) {
|
if (GameRenderer::isTransportActive()) {
|
||||||
@ -204,6 +262,8 @@ void PlayingState::update(double frameMs) {
|
|||||||
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||||
if (!ctx.game) return;
|
if (!ctx.game) return;
|
||||||
|
|
||||||
|
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||||
|
|
||||||
// Get current window size
|
// Get current window size
|
||||||
int winW = 0, winH = 0;
|
int winW = 0, winH = 0;
|
||||||
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
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)
|
// Render game content (no overlays)
|
||||||
// If a transport effect was requested due to a recent spawn, start it here so
|
// 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.
|
// 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);
|
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||||
s_pendingTransport = false;
|
s_pendingTransport = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
GameRenderer::renderPlayingState(
|
if (coopActive && ctx.coopGame) {
|
||||||
renderer,
|
GameRenderer::renderCoopPlayingState(
|
||||||
ctx.game,
|
renderer,
|
||||||
ctx.pixelFont,
|
ctx.coopGame,
|
||||||
ctx.lineEffect,
|
ctx.pixelFont,
|
||||||
ctx.blocksTex,
|
ctx.lineEffect,
|
||||||
ctx.asteroidsTex,
|
ctx.blocksTex,
|
||||||
ctx.statisticsPanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.scorePanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.nextPanelTex,
|
||||||
ctx.holdPanelTex,
|
ctx.holdPanelTex,
|
||||||
countdown,
|
1200.0f,
|
||||||
1200.0f, // LOGICAL_W
|
1000.0f,
|
||||||
1000.0f, // LOGICAL_H
|
logicalScale,
|
||||||
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)winW,
|
||||||
(float)winH,
|
(float)winH,
|
||||||
challengeClearFx,
|
challengeClearFx,
|
||||||
@ -272,7 +350,8 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
challengeClearDuration,
|
challengeClearDuration,
|
||||||
countdown ? nullptr : ctx.challengeStoryText,
|
countdown ? nullptr : ctx.challengeStoryText,
|
||||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset to screen
|
// Reset to screen
|
||||||
SDL_SetRenderTarget(renderer, nullptr);
|
SDL_SetRenderTarget(renderer, nullptr);
|
||||||
@ -341,33 +420,53 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Render normally directly to screen
|
// 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);
|
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||||
s_pendingTransport = false;
|
s_pendingTransport = false;
|
||||||
}
|
}
|
||||||
GameRenderer::renderPlayingState(
|
|
||||||
renderer,
|
if (coopActive && ctx.coopGame) {
|
||||||
ctx.game,
|
GameRenderer::renderCoopPlayingState(
|
||||||
ctx.pixelFont,
|
renderer,
|
||||||
ctx.lineEffect,
|
ctx.coopGame,
|
||||||
ctx.blocksTex,
|
ctx.pixelFont,
|
||||||
ctx.asteroidsTex,
|
ctx.lineEffect,
|
||||||
ctx.statisticsPanelTex,
|
ctx.blocksTex,
|
||||||
ctx.scorePanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.holdPanelTex,
|
ctx.nextPanelTex,
|
||||||
countdown,
|
ctx.holdPanelTex,
|
||||||
1200.0f,
|
1200.0f,
|
||||||
1000.0f,
|
1000.0f,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
(float)winW,
|
(float)winW,
|
||||||
(float)winH,
|
(float)winH
|
||||||
challengeClearFx,
|
);
|
||||||
challengeClearOrder,
|
} else {
|
||||||
challengeClearElapsed,
|
GameRenderer::renderPlayingState(
|
||||||
challengeClearDuration,
|
renderer,
|
||||||
countdown ? nullptr : ctx.challengeStoryText,
|
ctx.game,
|
||||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
// Forward declarations for frequently used types
|
// Forward declarations for frequently used types
|
||||||
class Game;
|
class Game;
|
||||||
|
class CoopGame;
|
||||||
class ScoreManager;
|
class ScoreManager;
|
||||||
class Starfield;
|
class Starfield;
|
||||||
class Starfield3D;
|
class Starfield3D;
|
||||||
@ -24,6 +25,7 @@ class StateManager;
|
|||||||
struct StateContext {
|
struct StateContext {
|
||||||
// Core subsystems (may be null if not available)
|
// Core subsystems (may be null if not available)
|
||||||
Game* game = nullptr;
|
Game* game = nullptr;
|
||||||
|
CoopGame* coopGame = nullptr;
|
||||||
ScoreManager* scores = nullptr;
|
ScoreManager* scores = nullptr;
|
||||||
Starfield* starfield = nullptr;
|
Starfield* starfield = nullptr;
|
||||||
Starfield3D* starfield3D = nullptr;
|
Starfield3D* starfield3D = nullptr;
|
||||||
|
|||||||
@ -22,12 +22,13 @@ 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 };
|
||||||
menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false };
|
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
|
||||||
menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true };
|
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
|
||||||
menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true };
|
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
|
||||||
menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true };
|
menu.buttons[4] = Button{ BottomMenuItem::Options, rects[4], "OPTIONS", true };
|
||||||
menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true };
|
menu.buttons[5] = Button{ BottomMenuItem::Help, rects[5], "HELP", true };
|
||||||
menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", true };
|
menu.buttons[6] = Button{ BottomMenuItem::About, rects[6], "ABOUT", true };
|
||||||
|
menu.buttons[7] = Button{ BottomMenuItem::Exit, rects[7], "EXIT", true };
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
@ -62,10 +63,15 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
|||||||
|
|
||||||
if (!b.textOnly) {
|
if (!b.textOnly) {
|
||||||
const bool isPlay = (i == 0);
|
const bool isPlay = (i == 0);
|
||||||
const bool isChallenge = (i == 1);
|
const bool isCoop = (i == 1);
|
||||||
|
const bool isChallenge = (i == 2);
|
||||||
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
||||||
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
||||||
if (isChallenge) {
|
if (isCoop) {
|
||||||
|
// Cooperative mode gets a cyan/magenta accent to separate from Endless/Challenge
|
||||||
|
bgCol = SDL_Color{ 22, 30, 40, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||||
|
bdCol = SDL_Color{ 160, 210, 255, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||||
|
} else if (isChallenge) {
|
||||||
// Give Challenge a teal accent to distinguish from Play
|
// Give Challenge a teal accent to distinguish from Play
|
||||||
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||||
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||||
@ -82,14 +88,14 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// '+' separators between the bottom HUD buttons (indices 2..last)
|
// '+' separators between the bottom HUD buttons (indices 3..last)
|
||||||
{
|
{
|
||||||
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||||
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
||||||
|
|
||||||
const int firstSmall = 2;
|
const int firstSmall = 3;
|
||||||
const int lastSmall = MENU_BTN_COUNT - 1;
|
const int lastSmall = MENU_BTN_COUNT - 1;
|
||||||
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
||||||
for (int i = firstSmall; i < lastSmall; ++i) {
|
for (int i = firstSmall; i < lastSmall; ++i) {
|
||||||
|
|||||||
@ -15,12 +15,13 @@ namespace ui {
|
|||||||
|
|
||||||
enum class BottomMenuItem : int {
|
enum class BottomMenuItem : int {
|
||||||
Play = 0,
|
Play = 0,
|
||||||
Challenge = 1,
|
Cooperate = 1,
|
||||||
Level = 2,
|
Challenge = 2,
|
||||||
Options = 3,
|
Level = 3,
|
||||||
Help = 4,
|
Options = 4,
|
||||||
About = 5,
|
Help = 5,
|
||||||
Exit = 6,
|
About = 6,
|
||||||
|
Exit = 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Button {
|
struct Button {
|
||||||
@ -37,8 +38,8 @@ struct BottomMenu {
|
|||||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel);
|
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel);
|
||||||
|
|
||||||
// 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..5
|
// hoveredIndex: -1..7
|
||||||
// selectedIndex: 0..5 (keyboard selection)
|
// selectedIndex: 0..7 (keyboard selection)
|
||||||
// alphaMul: 0..1 (overall group alpha)
|
// alphaMul: 0..1 (overall group alpha)
|
||||||
void renderBottomMenu(SDL_Renderer* renderer,
|
void renderBottomMenu(SDL_Renderer* renderer,
|
||||||
FontAtlas* font,
|
FontAtlas* font,
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
#include "ui/MenuLayout.h"
|
#include "ui/MenuLayout.h"
|
||||||
#include "ui/UIConstants.h"
|
#include "ui/UIConstants.h"
|
||||||
#include <cmath>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
||||||
|
|
||||||
// Cockpit HUD layout (matches main_screen art):
|
// Cockpit HUD layout (matches main_screen art):
|
||||||
// - Top row: PLAY and CHALLENGE (big buttons)
|
// - Top row: PLAY / COOPERATE / CHALLENGE (big buttons)
|
||||||
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
||||||
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
||||||
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
||||||
@ -26,9 +27,10 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
float smallSpacing = 26.0f;
|
float smallSpacing = 26.0f;
|
||||||
|
|
||||||
// Scale down for narrow windows so nothing goes offscreen.
|
// Scale down for narrow windows so nothing goes offscreen.
|
||||||
const int smallCount = MENU_BTN_COUNT - 2;
|
const int bigCount = 3;
|
||||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
const int smallCount = MENU_BTN_COUNT - bigCount;
|
||||||
float topRowTotal = playW * 2.0f + bigGap;
|
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(std::max(smallCount - 1, 0));
|
||||||
|
float topRowTotal = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||||
if (smallTotal > availableW || topRowTotal > availableW) {
|
if (smallTotal > availableW || topRowTotal > availableW) {
|
||||||
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
||||||
smallW *= s;
|
smallW *= s;
|
||||||
@ -48,11 +50,13 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
||||||
|
|
||||||
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
||||||
// Top row big buttons
|
// Top row big buttons (PLAY / COOPERATE / CHALLENGE)
|
||||||
float playLeft = centerX - (playW + bigGap * 0.5f);
|
float bigRowW = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||||
float challengeLeft = centerX + bigGap * 0.5f;
|
float leftBig = centerX - bigRowW * 0.5f;
|
||||||
rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH };
|
for (int i = 0; i < bigCount; ++i) {
|
||||||
rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH };
|
float x = leftBig + i * (playW + bigGap);
|
||||||
|
rects[i] = SDL_FRect{ x, playCY - playH * 0.5f, playW, playH };
|
||||||
|
}
|
||||||
|
|
||||||
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||||
float left = centerX - rowW * 0.5f;
|
float left = centerX - rowW * 0.5f;
|
||||||
@ -63,7 +67,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
|
|
||||||
for (int i = 0; i < smallCount; ++i) {
|
for (int i = 0; i < smallCount; ++i) {
|
||||||
float x = left + i * (smallW + smallSpacing);
|
float x = left + i * (smallW + smallSpacing);
|
||||||
rects[i + 2] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
rects[i + bigCount] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||||
}
|
}
|
||||||
return rects;
|
return rects;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ struct MenuLayoutParams {
|
|||||||
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
|
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
|
||||||
|
|
||||||
// Hit test a point given in logical content-local coordinates against menu buttons
|
// Hit test a point given in logical content-local coordinates against menu buttons
|
||||||
// Returns index 0..4 or -1 if none
|
// Returns index 0..(MENU_BTN_COUNT-1) or -1 if none
|
||||||
int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
|
int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
|
||||||
|
|
||||||
// Return settings button rect (logical coords)
|
// Return settings button rect (logical coords)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
static constexpr int MENU_BTN_COUNT = 7;
|
static constexpr int MENU_BTN_COUNT = 8;
|
||||||
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
||||||
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
||||||
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
||||||
|
|||||||
Reference in New Issue
Block a user