diff --git a/assets/images/asteroids_001.png b/assets/images/asteroids_001.png new file mode 100644 index 0000000..9a932e2 Binary files /dev/null and b/assets/images/asteroids_001.png differ diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 75d2224..a82ac30 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -137,6 +137,7 @@ struct TetrisApp::Impl { int mainScreenH = 0; SDL_Texture* blocksTex = nullptr; + SDL_Texture* asteroidsTex = nullptr; SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr; @@ -163,6 +164,7 @@ struct TetrisApp::Impl { std::vector tripleSounds; std::vector tetrisSounds; bool suppressLineVoiceForLevelUp = false; + bool skipNextLevelUpJingle = false; AppState state = AppState::Loading; double loadingProgress = 0.0; @@ -184,12 +186,18 @@ struct TetrisApp::Impl { float menuFadeAlpha = 0.0f; double MENU_PLAY_FADE_DURATION_MS = 450.0; AppState menuFadeTarget = AppState::Menu; + + enum class CountdownSource { MenuStart, ChallengeLevel }; bool menuPlayCountdownArmed = false; bool gameplayCountdownActive = false; double gameplayCountdownElapsed = 0.0; int gameplayCountdownIndex = 0; double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; std::array GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; + CountdownSource gameplayCountdownSource = CountdownSource::MenuStart; + int countdownLevel = 0; + int countdownGoalAsteroids = 0; + bool countdownAdvancesChallenge = false; double gameplayBackgroundClockMs = 0.0; std::unique_ptr stateMgr; @@ -369,8 +377,12 @@ int TetrisApp::Impl::init() }); game->setLevelUpCallback([this](int /*newLevel*/) { - SoundEffectManager::instance().playSound("new_level", 1.0f); - SoundEffectManager::instance().playSound("lets_go", 1.0f); + if (skipNextLevelUpJingle) { + skipNextLevelUpJingle = false; + } else { + SoundEffectManager::instance().playSound("new_level", 1.0f); + SoundEffectManager::instance().playSound("lets_go", 1.0f); + } suppressLineVoiceForLevelUp = true; }); @@ -419,6 +431,7 @@ int TetrisApp::Impl::init() ctx.logoSmallW = logoSmallW; ctx.logoSmallH = logoSmallH; ctx.backgroundTex = nullptr; + ctx.asteroidsTex = asteroidsTex; ctx.blocksTex = blocksTex; ctx.scorePanelTex = scorePanelTex; ctx.statisticsPanelTex = statisticsPanelTex; @@ -989,7 +1002,8 @@ void TetrisApp::Impl::runLoop() Assets::PANEL_SCORE, Assets::PANEL_STATS, Assets::NEXT_PANEL, - Assets::HOLD_PANEL + Assets::HOLD_PANEL, + Assets::ASTEROID_SPRITE }; for (auto &p : queuedPaths) { loadingManager->queueTexture(p); @@ -1024,6 +1038,7 @@ void TetrisApp::Impl::runLoop() logoSmallTex = assetLoader.getTexture(Assets::LOGO); mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); blocksTex = assetLoader.getTexture(Assets::BLOCKS_SPRITE); + asteroidsTex = assetLoader.getTexture(Assets::ASTEROID_SPRITE); scorePanelTex = assetLoader.getTexture(Assets::PANEL_SCORE); statisticsPanelTex = assetLoader.getTexture(Assets::PANEL_STATS); nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL); @@ -1056,6 +1071,7 @@ void TetrisApp::Impl::runLoop() legacyLoad(Assets::LOGO, logoSmallTex, &logoSmallW, &logoSmallH); legacyLoad(Assets::MAIN_SCREEN, mainScreenTex, &mainScreenW, &mainScreenH); legacyLoad(Assets::BLOCKS_SPRITE, blocksTex); + legacyLoad(Assets::ASTEROID_SPRITE, asteroidsTex); legacyLoad(Assets::PANEL_SCORE, scorePanelTex); legacyLoad(Assets::PANEL_STATS, statisticsPanelTex); legacyLoad(Assets::NEXT_PANEL, nextPanelTex); @@ -1208,12 +1224,30 @@ void TetrisApp::Impl::runLoop() break; } + if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !gameplayCountdownActive) { + int queuedLevel = game->consumeQueuedChallengeLevel(); + if (queuedLevel > 0) { + gameplayCountdownSource = CountdownSource::ChallengeLevel; + countdownLevel = queuedLevel; + countdownGoalAsteroids = queuedLevel; + countdownAdvancesChallenge = true; + gameplayCountdownActive = true; + menuPlayCountdownArmed = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + game->setPaused(true); + SoundEffectManager::instance().playSound("new_level", 1.0f); + skipNextLevelUpJingle = true; + } + } + ctx.logoTex = logoTex; ctx.logoSmallTex = logoSmallTex; ctx.logoSmallW = logoSmallW; ctx.logoSmallH = logoSmallH; ctx.backgroundTex = backgroundTex; ctx.blocksTex = blocksTex; + ctx.asteroidsTex = asteroidsTex; ctx.scorePanelTex = scorePanelTex; ctx.statisticsPanelTex = statisticsPanelTex; ctx.nextPanelTex = nextPanelTex; @@ -1232,6 +1266,12 @@ void TetrisApp::Impl::runLoop() } if (menuFadeTarget == AppState::Playing) { + gameplayCountdownSource = (game && game->getMode() == GameMode::Challenge) + ? CountdownSource::ChallengeLevel + : CountdownSource::MenuStart; + countdownLevel = game ? game->challengeLevel() : 1; + countdownGoalAsteroids = countdownLevel; + countdownAdvancesChallenge = false; menuPlayCountdownArmed = true; gameplayCountdownActive = false; gameplayCountdownIndex = 0; @@ -1259,6 +1299,12 @@ void TetrisApp::Impl::runLoop() } if (menuFadePhase == MenuFadePhase::None && menuPlayCountdownArmed && !gameplayCountdownActive && state == AppState::Playing) { + gameplayCountdownSource = (game && game->getMode() == GameMode::Challenge) + ? CountdownSource::ChallengeLevel + : CountdownSource::MenuStart; + countdownLevel = game ? game->challengeLevel() : 1; + countdownGoalAsteroids = countdownLevel; + countdownAdvancesChallenge = false; gameplayCountdownActive = true; menuPlayCountdownArmed = false; gameplayCountdownElapsed = 0.0; @@ -1275,6 +1321,10 @@ void TetrisApp::Impl::runLoop() gameplayCountdownActive = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel && countdownAdvancesChallenge && game) { + game->beginNextChallengeLevel(); + } + countdownAdvancesChallenge = false; game->setPaused(false); } } @@ -1285,6 +1335,7 @@ void TetrisApp::Impl::runLoop() menuPlayCountdownArmed = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; + countdownAdvancesChallenge = false; game->setPaused(false); } @@ -1520,6 +1571,7 @@ void TetrisApp::Impl::runLoop() &pixelFont, &lineEffect, blocksTex, + asteroidsTex, ctx.statisticsPanelTex, scorePanelTex, nextPanelTex, @@ -1674,6 +1726,27 @@ void TetrisApp::Impl::runLoop() float textX = (winW - static_cast(textW)) * 0.5f; float textY = (winH - static_cast(textH)) * 0.5f; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel) { + char levelBuf[32]; + std::snprintf(levelBuf, sizeof(levelBuf), "LEVEL %d", countdownLevel); + int lvlW = 0, lvlH = 0; + float lvlScale = 2.5f; + pixelFont.measure(levelBuf, lvlScale, lvlW, lvlH); + float levelX = (winW - static_cast(lvlW)) * 0.5f; + float levelY = winH * 0.32f; + pixelFont.draw(renderer, levelX, levelY, levelBuf, lvlScale, SDL_Color{140, 210, 255, 255}); + + char goalBuf[64]; + std::snprintf(goalBuf, sizeof(goalBuf), "ASTEROIDS: %d", countdownGoalAsteroids); + int goalW = 0, goalH = 0; + float goalScale = 1.7f; + pixelFont.measure(goalBuf, goalScale, goalW, goalH); + float goalX = (winW - static_cast(goalW)) * 0.5f; + float goalY = levelY + static_cast(lvlH) + 14.0f; + pixelFont.draw(renderer, goalX, goalY, goalBuf, goalScale, SDL_Color{220, 245, 255, 255}); + + textY = goalY + static_cast(goalH) + 38.0f; + } SDL_Color textColor = isFinalCue ? SDL_Color{255, 230, 90, 255} : SDL_Color{255, 255, 255, 255}; pixelFont.draw(renderer, textX, textY, label, textScale, textColor); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index bd22541..fe68192 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -643,6 +643,7 @@ bool ApplicationManager::initializeGame() { } else { m_stateContext.logoSmallW = 0; m_stateContext.logoSmallH = 0; } m_stateContext.backgroundTex = m_assetManager->getTexture("background"); m_stateContext.blocksTex = m_assetManager->getTexture("blocks"); + m_stateContext.asteroidsTex = m_assetManager->getTexture("asteroids"); m_stateContext.musicEnabled = &m_musicEnabled; m_stateContext.musicStarted = &m_musicStarted; m_stateContext.musicLoaded = &m_musicLoaded; @@ -1162,6 +1163,7 @@ void ApplicationManager::setupStateHandlers() { m_stateContext.pixelFont, m_stateContext.lineEffect, m_stateContext.blocksTex, + m_stateContext.asteroidsTex, m_stateContext.statisticsPanelTex, m_stateContext.scorePanelTex, m_stateContext.nextPanelTex, diff --git a/src/gameplay/core/Game.cpp b/src/gameplay/core/Game.cpp index b4a9109..3d1ef26 100644 --- a/src/gameplay/core/Game.cpp +++ b/src/gameplay/core/Game.cpp @@ -64,6 +64,8 @@ void Game::reset(int startLevel_) { _comboCount = 0; challengeComplete = false; challengeLevelActive = false; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; // Initialize gravity using NES timing table (ms per cell by level) gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier); fallAcc = 0; gameOver=false; paused=false; @@ -109,6 +111,8 @@ void Game::startChallengeRun(int startingLevel) { challengeSeedBase = static_cast(SDL_GetTicks()); } challengeRng.seed(challengeSeedBase + static_cast(lvl)); + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; setupChallengeLevel(lvl, false); } @@ -116,6 +120,8 @@ void Game::beginNextChallengeLevel() { if (mode != GameMode::Challenge || challengeComplete) { return; } + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; int next = challengeLevelIndex + 1; if (next > ASTEROID_MAX_LEVEL) { challengeComplete = true; @@ -131,6 +137,8 @@ void Game::setupChallengeLevel(int level, bool preserveStats) { startLevel = challengeLevelIndex; challengeComplete = false; challengeLevelActive = true; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; // Refresh deterministic RNG for this level challengeRng.seed(challengeSeedBase + static_cast(challengeLevelIndex)); @@ -318,6 +326,16 @@ void Game::setPaused(bool p) { paused = p; } +int Game::consumeQueuedChallengeLevel() { + if (!challengeAdvanceQueued) { + return 0; + } + int next = challengeQueuedLevel; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + return next; +} + void Game::setSoftDropping(bool on) { if (softDropping == on) { return; @@ -521,7 +539,18 @@ void Game::actualClearLines() { if (mode == GameMode::Challenge) { if (asteroidsRemainingCount <= 0) { - beginNextChallengeLevel(); + int nextLevel = challengeLevelIndex + 1; + if (nextLevel > ASTEROID_MAX_LEVEL) { + challengeComplete = true; + challengeLevelActive = false; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + } else { + challengeAdvanceQueued = true; + challengeQueuedLevel = nextLevel; + challengeLevelActive = false; + setPaused(true); + } } } } diff --git a/src/gameplay/core/Game.h b/src/gameplay/core/Game.h index c5b7fbd..a8f0176 100644 --- a/src/gameplay/core/Game.h +++ b/src/gameplay/core/Game.h @@ -67,6 +67,10 @@ public: int asteroidsRemaining() const { return asteroidsRemainingCount; } int asteroidsTotal() const { return asteroidsTotalThisLevel; } bool isChallengeComplete() const { return challengeComplete; } + bool isChallengeLevelActive() const { return challengeLevelActive; } + bool isChallengeAdvanceQueued() const { return challengeAdvanceQueued; } + int queuedChallengeLevel() const { return challengeQueuedLevel; } + int consumeQueuedChallengeLevel(); // returns next level if queued, else 0 int startLevelBase() const { return startLevel; } double elapsed() const; // Now calculated from start time void updateElapsedTime(); // Update elapsed time from system clock @@ -168,6 +172,8 @@ private: uint32_t challengeSeedBase{0}; std::mt19937 challengeRng{ std::random_device{}() }; bool challengeLevelActive{false}; + bool challengeAdvanceQueued{false}; + int challengeQueuedLevel{0}; // Internal helpers ---------------------------------------------------- void refillBag(); diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index d707772..8799191 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -282,6 +282,62 @@ void GameRenderer::drawRect(SDL_Renderer* renderer, float x, float y, float w, f SDL_RenderFillRect(renderer, &fr); } +static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float x, float y, float size, const AsteroidCell& cell) { + auto outlineGravity = [&](float inset, SDL_Color color) { + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + SDL_FRect glow{ x + inset, y + inset, size - inset * 2.0f, size - inset * 2.0f }; + SDL_RenderRect(renderer, &glow); + }; + + if (asteroidTex) { + const float SPRITE_SIZE = 90.0f; + int col = 0; + switch (cell.type) { + case AsteroidType::Normal: col = 0; break; + case AsteroidType::Armored: col = 1; break; + case AsteroidType::Falling: col = 2; break; + case AsteroidType::Core: col = 3; break; + } + int row = std::clamp(cell.visualState, 0, 2); + SDL_FRect src{ col * SPRITE_SIZE, row * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE }; + SDL_FRect dst{ x, y, size, size }; + SDL_RenderTexture(renderer, asteroidTex, &src, &dst); + + if (cell.gravityEnabled) { + outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); + } + return; + } + + // Fallback: draw a colored quad (previous implementation) + SDL_Color base{}; + switch (cell.type) { + case AsteroidType::Normal: base = SDL_Color{172, 138, 104, 255}; break; + case AsteroidType::Armored: base = SDL_Color{130, 150, 176, 255}; break; + case AsteroidType::Falling: base = SDL_Color{210, 120, 82, 255}; break; + case AsteroidType::Core: base = SDL_Color{198, 78, 200, 255}; break; + } + float hpScale = std::clamp(static_cast(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f); + SDL_Color fill{ + static_cast(base.r * hpScale + 40 * (1.0f - hpScale)), + static_cast(base.g * hpScale + 40 * (1.0f - hpScale)), + static_cast(base.b * hpScale + 40 * (1.0f - hpScale)), + 255 + }; + SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); + SDL_FRect body{x, y, size - 1.0f, size - 1.0f}; + SDL_RenderFillRect(renderer, &body); + + SDL_Color outline = base; + outline.a = 220; + SDL_FRect border{x + 1.0f, y + 1.0f, size - 2.0f, size - 2.0f}; + SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a); + SDL_RenderRect(renderer, &border); + if (cell.gravityEnabled) { + outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); + } +} + void GameRenderer::drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) { if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) { // Fallback to colored rectangle if texture isn't available @@ -515,6 +571,7 @@ void GameRenderer::renderPlayingState( FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* asteroidsTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, @@ -960,32 +1017,7 @@ void GameRenderer::renderPlayingState( bool isAsteroid = challengeMode && asteroidCells[cellIdx].has_value(); if (isAsteroid) { const AsteroidCell& cell = *asteroidCells[cellIdx]; - SDL_Color base{}; - switch (cell.type) { - case AsteroidType::Normal: base = SDL_Color{172, 138, 104, 255}; break; - case AsteroidType::Armored: base = SDL_Color{130, 150, 176, 255}; break; - case AsteroidType::Falling: base = SDL_Color{210, 120, 82, 255}; break; - case AsteroidType::Core: base = SDL_Color{198, 78, 200, 255}; break; - } - float hpScale = std::clamp(static_cast(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f); - SDL_Color fill{ - static_cast(base.r * hpScale + 40 * (1.0f - hpScale)), - static_cast(base.g * hpScale + 40 * (1.0f - hpScale)), - static_cast(base.b * hpScale + 40 * (1.0f - hpScale)), - 255 - }; - drawRect(renderer, bx, by, finalBlockSize - 1, finalBlockSize - 1, fill); - // Subtle outline to differentiate types - SDL_Color outline = base; - outline.a = 220; - SDL_FRect border{bx + 1.0f, by + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f}; - SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a); - SDL_RenderRect(renderer, &border); - if (cell.gravityEnabled) { - SDL_SetRenderDrawColor(renderer, 255, 230, 120, 180); - SDL_FRect glow{bx + 2.0f, by + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f}; - SDL_RenderRect(renderer, &glow); - } + drawAsteroid(renderer, asteroidsTex, bx, by, finalBlockSize, cell); } else { drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1); } diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index c4c730e..39e6a45 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -21,6 +21,7 @@ public: FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* asteroidsTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, diff --git a/src/resources/AssetPaths.h b/src/resources/AssetPaths.h index ba58799..d050ac7 100644 --- a/src/resources/AssetPaths.h +++ b/src/resources/AssetPaths.h @@ -7,7 +7,8 @@ namespace Assets { inline constexpr const char* LOGO = "assets/images/spacetris.png"; inline constexpr const char* MAIN_SCREEN = "assets/images/main_screen.png"; - inline constexpr const char* BLOCKS_SPRITE = "assets/images/blocks90px_003.png"; + inline constexpr const char* BLOCKS_SPRITE = "assets/images/blocks90px_003.png"; + inline constexpr const char* ASTEROID_SPRITE = "assets/images/asteroids_001.png"; inline constexpr const char* PANEL_SCORE = "assets/images/panel_score.png"; inline constexpr const char* PANEL_STATS = "assets/images/statistics_panel.png"; inline constexpr const char* NEXT_PANEL = "assets/images/next_panel.png"; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 4796494..1ff9162 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -241,6 +241,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.pixelFont, ctx.lineEffect, ctx.blocksTex, + ctx.asteroidsTex, ctx.statisticsPanelTex, ctx.scorePanelTex, ctx.nextPanelTex, @@ -329,6 +330,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.pixelFont, ctx.lineEffect, ctx.blocksTex, + ctx.asteroidsTex, ctx.statisticsPanelTex, ctx.scorePanelTex, ctx.nextPanelTex, diff --git a/src/states/State.h b/src/states/State.h index 36a7a95..ddd3ecc 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -40,6 +40,7 @@ struct StateContext { // backgroundTex is set once in `main.cpp` and passed to states via this context. // Prefer reading this field instead of relying on any `extern SDL_Texture*` globals. SDL_Texture* blocksTex = nullptr; + SDL_Texture* asteroidsTex = nullptr; SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr;