diff --git a/assets/images/asteroids_001.png b/assets/images/asteroids_001.png index 3a185f9..f488f51 100644 Binary files a/assets/images/asteroids_001.png and b/assets/images/asteroids_001.png differ diff --git a/assets/music/asteroid-destroy.mp3 b/assets/music/asteroid-destroy.mp3 new file mode 100644 index 0000000..f6338cc Binary files /dev/null and b/assets/music/asteroid-destroy.mp3 differ diff --git a/settings.ini b/settings.ini index d68ba68..99029f7 100644 --- a/settings.ini +++ b/settings.ini @@ -6,7 +6,7 @@ Fullscreen=1 [Audio] Music=1 -Sound=0 +Sound=1 [Gameplay] SmoothScroll=1 diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index aacaebb..b3998df 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -200,6 +200,14 @@ struct TetrisApp::Impl { bool countdownAdvancesChallenge = false; double gameplayBackgroundClockMs = 0.0; + // Challenge clear FX (celebratory board explosion before countdown) + bool challengeClearFxActive = false; + double challengeClearFxElapsedMs = 0.0; + double challengeClearFxDurationMs = 0.0; + int challengeClearFxNextLevel = 0; + std::vector challengeClearFxOrder; + std::mt19937 challengeClearFxRng{std::random_device{}()}; + std::unique_ptr stateMgr; StateContext ctx{}; std::unique_ptr loadingState; @@ -386,6 +394,10 @@ int TetrisApp::Impl::init() suppressLineVoiceForLevelUp = true; }); + game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) { + SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f); + }); + state = AppState::Loading; loadingProgress = 0.0; loadStart = SDL_GetTicks(); @@ -448,6 +460,10 @@ int TetrisApp::Impl::init() ctx.exitPopupSelectedButton = &exitPopupSelectedButton; ctx.gameplayCountdownActive = &gameplayCountdownActive; ctx.menuPlayCountdownArmed = &menuPlayCountdownArmed; + ctx.challengeClearFxActive = &challengeClearFxActive; + ctx.challengeClearFxElapsedMs = &challengeClearFxElapsedMs; + ctx.challengeClearFxDurationMs = &challengeClearFxDurationMs; + ctx.challengeClearFxOrder = &challengeClearFxOrder; ctx.playerName = &playerName; ctx.fullscreenFlag = &isFullscreen; ctx.applyFullscreen = [this](bool enable) { @@ -568,6 +584,37 @@ void TetrisApp::Impl::runLoop() } }; + auto startChallengeClearFx = [this](int nextLevel) { + challengeClearFxOrder.clear(); + const auto& boardRef = game->boardRef(); + const auto& asteroidRef = game->asteroidCells(); + for (int idx = 0; idx < Game::COLS * Game::ROWS; ++idx) { + if (boardRef[idx] != 0 || asteroidRef[idx].has_value()) { + challengeClearFxOrder.push_back(idx); + } + } + if (challengeClearFxOrder.empty()) { + challengeClearFxOrder.reserve(Game::COLS * Game::ROWS); + for (int idx = 0; idx < Game::COLS * Game::ROWS; ++idx) { + challengeClearFxOrder.push_back(idx); + } + } + std::shuffle(challengeClearFxOrder.begin(), challengeClearFxOrder.end(), challengeClearFxRng); + + challengeClearFxElapsedMs = 0.0; + challengeClearFxDurationMs = std::clamp(800.0 + static_cast(challengeClearFxOrder.size()) * 8.0, 900.0, 2600.0); + challengeClearFxNextLevel = nextLevel; + challengeClearFxActive = true; + gameplayCountdownActive = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + menuPlayCountdownArmed = false; + if (game) { + game->setPaused(true); + } + SoundEffectManager::instance().playSound("challenge_clear", 0.8f); + }; + while (running) { if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) { @@ -756,6 +803,8 @@ void TetrisApp::Impl::runLoop() case ui::BottomMenuItem::Challenge: if (game) { game->setMode(GameMode::Challenge); + // Suppress the initial level-up jingle when starting Challenge from menu + skipNextLevelUpJingle = true; game->startChallengeRun(1); } startMenuPlayTransition(); @@ -876,6 +925,32 @@ void TetrisApp::Impl::runLoop() if (frameMs > 100.0) frameMs = 100.0; gameplayBackgroundClockMs += frameMs; + if (challengeClearFxActive) { + challengeClearFxElapsedMs += frameMs; + if (challengeClearFxElapsedMs >= challengeClearFxDurationMs) { + challengeClearFxElapsedMs = challengeClearFxDurationMs; + challengeClearFxActive = false; + if (challengeClearFxNextLevel > 0) { + // Advance to the next challenge level immediately so the countdown shows the new board/asteroids + if (game) { + game->beginNextChallengeLevel(); + game->setPaused(true); + } + gameplayCountdownSource = CountdownSource::ChallengeLevel; + countdownLevel = challengeClearFxNextLevel; + countdownGoalAsteroids = challengeClearFxNextLevel; + countdownAdvancesChallenge = false; // already advanced + gameplayCountdownActive = true; + menuPlayCountdownArmed = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + SoundEffectManager::instance().playSound("new_level", 1.0f); + skipNextLevelUpJingle = true; + } + challengeClearFxNextLevel = 0; + } + } + const bool *ks = SDL_GetKeyboardState(nullptr); bool left = state == AppState::Playing && ks[SDL_SCANCODE_LEFT]; bool right = state == AppState::Playing && ks[SDL_SCANCODE_RIGHT]; @@ -1013,9 +1088,9 @@ void TetrisApp::Impl::runLoop() SoundEffectManager::instance().init(); loadedTasks.fetch_add(1); - const std::vector audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level"}; + const std::vector audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level","asteroid_destroy","challenge_clear"}; for (const auto &id : audioIds) { - std::string basePath = "assets/music/" + (id == "hard_drop" ? "hard_drop_001" : id); + std::string basePath = "assets/music/" + (id == "hard_drop" ? "hard_drop_001" : (id == "challenge_clear" ? "GONG0" : id)); { std::lock_guard lk(currentLoadingMutex); currentLoadingFile = basePath; @@ -1224,20 +1299,10 @@ void TetrisApp::Impl::runLoop() break; } - if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !gameplayCountdownActive) { + if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !gameplayCountdownActive && !challengeClearFxActive) { 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; + startChallengeClearFx(queuedLevel); } } @@ -1339,6 +1404,14 @@ void TetrisApp::Impl::runLoop() game->setPaused(false); } + if (state != AppState::Playing && challengeClearFxActive) { + challengeClearFxActive = false; + challengeClearFxElapsedMs = 0.0; + challengeClearFxDurationMs = 0.0; + challengeClearFxNextLevel = 0; + challengeClearFxOrder.clear(); + } + SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_RenderClear(renderer); diff --git a/src/gameplay/core/Game.cpp b/src/gameplay/core/Game.cpp index 3d1ef26..bf24a4d 100644 --- a/src/gameplay/core/Game.cpp +++ b/src/gameplay/core/Game.cpp @@ -567,6 +567,7 @@ void Game::handleAsteroidsOnClearedRows(const std::vector& clearedRows, // Track asteroid count updates during processing int destroyedThisPass = 0; + std::optional lastDestroyedType; // Precompute how many cleared rows are at or below each row to reposition survivors std::array clearedBelow{}; @@ -595,6 +596,7 @@ void Game::handleAsteroidsOnClearedRows(const std::vector& clearedRows, } if (cell.hitsRemaining == 0) { destroyedThisPass++; + lastDestroyedType = cell.type; continue; } @@ -627,6 +629,9 @@ void Game::handleAsteroidsOnClearedRows(const std::vector& clearedRows, if (destroyedThisPass > 0) { asteroidsRemainingCount = std::max(0, asteroidsRemainingCount - destroyedThisPass); + if (asteroidDestroyedCallback && lastDestroyedType.has_value()) { + asteroidDestroyedCallback(*lastDestroyedType); + } } } diff --git a/src/gameplay/core/Game.h b/src/gameplay/core/Game.h index a8f0176..57587c2 100644 --- a/src/gameplay/core/Game.h +++ b/src/gameplay/core/Game.h @@ -88,8 +88,10 @@ public: // Sound effect callbacks using SoundCallback = std::function; // Callback for line clear sounds (number of lines) using LevelUpCallback = std::function; // Callback for level up sounds + using AsteroidDestroyedCallback = std::function; // Callback when an asteroid is fully destroyed void setSoundCallback(SoundCallback callback) { soundCallback = callback; } void setLevelUpCallback(LevelUpCallback callback) { levelUpCallback = callback; } + void setAsteroidDestroyedCallback(AsteroidDestroyedCallback callback) { asteroidDestroyedCallback = callback; } // Shape helper -------------------------------------------------------- static bool cellFilled(const Piece& p, int cx, int cy); @@ -147,6 +149,7 @@ private: // Sound effect callbacks SoundCallback soundCallback; LevelUpCallback levelUpCallback; + AsteroidDestroyedCallback asteroidDestroyedCallback; // Gravity tuning ----------------------------------------------------- // Global multiplier applied to all level timings (use to slow/speed whole-game gravity) double gravityGlobalMultiplier{1.0}; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 5a27baf..36b49bf 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -581,7 +581,11 @@ void GameRenderer::renderPlayingState( float logicalH, float logicalScale, float winW, - float winH + float winH, + bool challengeClearFxActive, + const std::vector* challengeClearFxOrder, + double challengeClearFxElapsedMs, + double challengeClearFxDurationMs ) { if (!game || !pixelFont) return; @@ -997,6 +1001,25 @@ void GameRenderer::renderPlayingState( } } + std::array challengeClearMask{}; + const bool challengeClearActive = challengeClearFxActive && challengeClearFxOrder && !challengeClearFxOrder->empty() && challengeClearFxDurationMs > 0.0; + if (challengeClearActive) { + const double totalDuration = std::max(50.0, challengeClearFxDurationMs); + const double perCell = totalDuration / static_cast(challengeClearFxOrder->size()); + for (size_t i = 0; i < challengeClearFxOrder->size(); ++i) { + int idx = (*challengeClearFxOrder)[i]; + if (idx < 0 || idx >= static_cast(challengeClearMask.size())) { + continue; + } + double startMs = perCell * static_cast(i); + double local = (challengeClearFxElapsedMs - startMs) / perCell; + float progress = static_cast(std::clamp(local, 0.0, 1.0)); + if (progress > 0.0f) { + challengeClearMask[idx] = progress; + } + } + } + for (int y = 0; y < Game::ROWS; ++y) { float dropOffset = rowDropOffsets[y]; for (int x = 0; x < Game::COLS; ++x) { @@ -1015,6 +1038,21 @@ void GameRenderer::renderPlayingState( by += amplitude * 0.75f * std::cos(t * (freq + 1.1f)); } + float clearProgress = challengeClearMask[cellIdx]; + float clearAlpha = 1.0f; + float clearScale = 1.0f; + if (clearProgress > 0.0f) { + float eased = smoothstep(clearProgress); + clearAlpha = std::max(0.0f, 1.0f - eased); + clearScale = 1.0f + 0.35f * eased; + float offset = (finalBlockSize - finalBlockSize * clearScale) * 0.5f; + bx += offset; + by += offset; + float jitter = eased * 2.0f; + bx += std::sin(static_cast(cellIdx) * 3.1f) * jitter; + by += std::cos(static_cast(cellIdx) * 2.3f) * jitter * 0.6f; + } + bool isAsteroid = challengeMode && asteroidCells[cellIdx].has_value(); if (isAsteroid) { const AsteroidCell& cell = *asteroidCells[cellIdx]; @@ -1034,15 +1072,25 @@ void GameRenderer::renderPlayingState( SDL_SetTextureAlphaMod(asteroidsTex, static_cast(std::clamp(spawnAlpha, 0.0f, 1.0f) * 255.0f)); } - float size = finalBlockSize * spawnScale; + float size = finalBlockSize * spawnScale * clearScale; float offset = (finalBlockSize - size) * 0.5f; + if (asteroidsTex && clearAlpha < 1.0f) { + Uint8 alpha = static_cast(std::clamp(spawnAlpha * clearAlpha, 0.0f, 1.0f) * 255.0f); + SDL_SetTextureAlphaMod(asteroidsTex, alpha); + } drawAsteroid(renderer, asteroidsTex, bx + offset, by + offset, size, cell); - if (asteroidsTex && spawnAlpha < 1.0f) { + if (asteroidsTex && (spawnAlpha < 1.0f || clearAlpha < 1.0f)) { SDL_SetTextureAlphaMod(asteroidsTex, 255); } } else { - drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1); + if (blocksTex && clearAlpha < 1.0f) { + SDL_SetTextureAlphaMod(blocksTex, static_cast(std::clamp(clearAlpha, 0.0f, 1.0f) * 255.0f)); + } + drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize * clearScale, v - 1); + if (blocksTex && clearAlpha < 1.0f) { + SDL_SetTextureAlphaMod(blocksTex, 255); + } } } } @@ -1075,7 +1123,7 @@ void GameRenderer::renderPlayingState( } } - bool allowActivePieceRender = !GameRenderer::isTransportActive(); + bool allowActivePieceRender = !GameRenderer::isTransportActive() && !challengeClearActive; const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled(); float activePiecePixelOffsetX = 0.0f; diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index 4f77b29..4c87769 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include "../../gameplay/core/Game.h" // Forward declarations @@ -31,7 +32,11 @@ public: float logicalH, float logicalScale, float winW, - float winH + float winH, + bool challengeClearFxActive = false, + const std::vector* challengeClearFxOrder = nullptr, + double challengeClearFxElapsedMs = 0.0, + double challengeClearFxDurationMs = 0.0 ); // Render the pause overlay (full screen) diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index a9bbe14..b841e6e 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -221,11 +221,15 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l bool exitPopup = ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup; bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) || (ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed); + bool challengeClearFx = ctx.challengeClearFxActive && *ctx.challengeClearFxActive; + const std::vector* challengeClearOrder = ctx.challengeClearFxOrder; + double challengeClearElapsed = ctx.challengeClearFxElapsedMs ? *ctx.challengeClearFxElapsedMs : 0.0; + double challengeClearDuration = ctx.challengeClearFxDurationMs ? *ctx.challengeClearFxDurationMs : 0.0; // Only blur if paused AND NOT in countdown (and not exit popup, though exit popup implies paused) // Actually, exit popup should probably still blur/dim. // But countdown should definitely NOT show the "PAUSED" overlay. - bool shouldBlur = paused && !countdown; + bool shouldBlur = paused && !countdown && !challengeClearFx; if (shouldBlur && m_renderTarget) { // Render game to texture @@ -260,8 +264,12 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l 1200.0f, // LOGICAL_W 1000.0f, // LOGICAL_H logicalScale, - (float)winW, - (float)winH + (float)winW, + (float)winH, + challengeClearFx, + challengeClearOrder, + challengeClearElapsed, + challengeClearDuration ); // Reset to screen @@ -351,7 +359,11 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l 1000.0f, logicalScale, (float)winW, - (float)winH + (float)winH, + challengeClearFx, + challengeClearOrder, + challengeClearElapsed, + challengeClearDuration ); } } diff --git a/src/states/State.h b/src/states/State.h index ddd3ecc..1a66418 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -67,6 +67,11 @@ struct StateContext { int* exitPopupSelectedButton = nullptr; // 0 = YES, 1 = NO (default) bool* gameplayCountdownActive = nullptr; // True if start-of-game countdown is running bool* menuPlayCountdownArmed = nullptr; // True if we are transitioning to play and countdown is pending + // Challenge clear FX (slow block-by-block explosion before next level) + bool* challengeClearFxActive = nullptr; + double* challengeClearFxElapsedMs = nullptr; + double* challengeClearFxDurationMs = nullptr; + std::vector* challengeClearFxOrder = nullptr; std::string* playerName = nullptr; // Shared player name buffer for highscores/options bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available std::function applyFullscreen; // Allows states to request fullscreen changes