diff --git a/assets/music/hard_drop_001.mp3 b/assets/music/hard_drop_001.mp3 new file mode 100644 index 0000000..7b6f1fa Binary files /dev/null and b/assets/music/hard_drop_001.mp3 differ diff --git a/assets/music/new_level.mp3 b/assets/music/new_level.mp3 new file mode 100644 index 0000000..d8d4522 Binary files /dev/null and b/assets/music/new_level.mp3 differ diff --git a/src/gameplay/core/Game.cpp b/src/gameplay/core/Game.cpp index 2196648..8342ce0 100644 --- a/src/gameplay/core/Game.cpp +++ b/src/gameplay/core/Game.cpp @@ -57,6 +57,9 @@ void Game::reset(int startLevel_) { // Initialize gravity using NES timing table (ms per cell by level) gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier); fallAcc = 0; gameOver=false; paused=false; + hardDropShakeTimerMs = 0.0; + hardDropCells.clear(); + hardDropFxId = 0; _startTime = SDL_GetPerformanceCounter(); _pausedTime = 0; _lastPauseStart = 0; @@ -328,6 +331,25 @@ void Game::softDropBoost(double frameMs) { (void)frameMs; } +void Game::updateVisualEffects(double frameMs) { + if (frameMs <= 0.0) { + return; + } + + if (hardDropShakeTimerMs <= 0.0) { + hardDropShakeTimerMs = 0.0; + if (!hardDropCells.empty()) { + hardDropCells.clear(); + } + return; + } + + hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs); + if (hardDropShakeTimerMs <= 0.0 && !hardDropCells.empty()) { + hardDropCells.clear(); + } +} + void Game::hardDrop() { if (paused) return; // Count how many rows we drop for scoring parity with JS @@ -337,7 +359,34 @@ void Game::hardDrop() { if (rows > 0) { _score += rows * 1; } - lockPiece(); + hardDropCells.clear(); + hardDropCells.reserve(8); + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!cellFilled(cur, cx, cy)) { + continue; + } + int gx = cur.x + cx; + int gy = cur.y + cy; + if (gx < 0 || gx >= COLS || gy >= ROWS) { + continue; + } + if (gy >= 0) { + hardDropCells.push_back(SDL_Point{gx, gy}); + } + } + } + + ++hardDropFxId; + lockPiece(); + hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS; +} + +double Game::hardDropShakeStrength() const { + if (hardDropShakeTimerMs <= 0.0) { + return 0.0; + } + return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0); } void Game::rotate(int dir) { diff --git a/src/gameplay/core/Game.h b/src/gameplay/core/Game.h index 48de705..8fd26b1 100644 --- a/src/gameplay/core/Game.h +++ b/src/gameplay/core/Game.h @@ -74,6 +74,13 @@ public: double getFallAccumulator() const { return fallAcc; } // Debug: time accumulated toward next drop void setLevelGravityMultiplier(int level, double m); + // Visual effect hooks + void updateVisualEffects(double frameMs); + bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; } + double hardDropShakeStrength() const; + const std::vector& getHardDropCells() const { return hardDropCells; } + uint32_t getHardDropFxId() const { return hardDropFxId; } + private: std::array board{}; // 0 empty else color index Piece cur{}, hold{}, nextPiece{}; // current, held & next piece @@ -109,6 +116,12 @@ private: // Backwards-compatible accessors (delegate to gravityMgr) double computeGravityMsForLevel(int level) const; + // Impact FX timers + double hardDropShakeTimerMs{0.0}; + static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0; + std::vector hardDropCells; + uint32_t hardDropFxId{0}; + // Internal helpers ---------------------------------------------------- void refillBag(); void spawn(); diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 074d586..b280d37 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -6,8 +6,23 @@ #include #include #include +#include +#include #include "../../core/Settings.h" +namespace { +struct ImpactSpark { + float x = 0.0f; + float y = 0.0f; + float vx = 0.0f; + float vy = 0.0f; + float lifeMs = 0.0f; + float maxLifeMs = 0.0f; + float size = 0.0f; + SDL_Color color{255, 255, 255, 255}; +}; +} + // Color constants (copied from main.cpp) static const SDL_Color COLORS[] = { {0, 0, 0, 255}, // 0: BLACK (empty) @@ -126,6 +141,18 @@ void GameRenderer::renderPlayingState( float winH ) { if (!game || !pixelFont) return; + + static std::vector s_impactSparks; + static uint32_t s_lastImpactFxId = 0; + static Uint32 s_lastImpactTick = SDL_GetTicks(); + static std::mt19937 s_impactRng{ std::random_device{}() }; + + Uint32 nowTicks = SDL_GetTicks(); + float sparkDeltaMs = static_cast(nowTicks - s_lastImpactTick); + s_lastImpactTick = nowTicks; + if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) { + sparkDeltaMs = 16.0f; + } // Calculate actual content area (centered within the window) float contentScale = logicalScale; @@ -223,19 +250,132 @@ void GameRenderer::renderPlayingState( drawRectWithOffset(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255}); drawRectWithOffset(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255}); + // Precompute row drop offsets (line collapse effect) + std::array rowDropOffsets{}; + for (int y = 0; y < Game::ROWS; ++y) { + rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f); + } + // Draw the game board const auto &board = game->boardRef(); + float impactStrength = 0.0f; + float impactEased = 0.0f; + std::array impactMask{}; + std::array impactWeight{}; + if (game->hasHardDropShake()) { + impactStrength = static_cast(game->hardDropShakeStrength()); + impactStrength = std::clamp(impactStrength, 0.0f, 1.0f); + impactEased = impactStrength * impactStrength; + const auto& impactCells = game->getHardDropCells(); + for (const auto& cell : impactCells) { + if (cell.x < 0 || cell.x >= Game::COLS || cell.y < 0 || cell.y >= Game::ROWS) { + continue; + } + int idx = cell.y * Game::COLS + cell.x; + impactMask[idx] = 1; + impactWeight[idx] = 1.0f; + + int depth = 0; + for (int ny = cell.y + 1; ny < Game::ROWS && depth < 4; ++ny) { + if (board[ny * Game::COLS + cell.x] == 0) { + break; + } + ++depth; + int nidx = ny * Game::COLS + cell.x; + impactMask[nidx] = 1; + float weight = std::max(0.15f, 1.0f - depth * 0.35f); + impactWeight[nidx] = std::max(impactWeight[nidx], weight); + } + } + } + + bool shouldSpawnCrackles = game->hasHardDropShake() && !game->getHardDropCells().empty() && game->getHardDropFxId() != s_lastImpactFxId; + if (shouldSpawnCrackles) { + s_lastImpactFxId = game->getHardDropFxId(); + std::uniform_real_distribution jitter(-finalBlockSize * 0.2f, finalBlockSize * 0.2f); + std::uniform_real_distribution velX(-0.04f, 0.04f); + std::uniform_real_distribution velY(0.035f, 0.07f); + std::uniform_real_distribution lifespan(210.0f, 320.0f); + std::uniform_real_distribution sizeDist(finalBlockSize * 0.08f, finalBlockSize * 0.14f); + + const auto& impactCells = game->getHardDropCells(); + for (const auto& cell : impactCells) { + if (cell.x < 0 || cell.x >= Game::COLS || cell.y < 0 || cell.y >= Game::ROWS) { + continue; + } + int idx = cell.y * Game::COLS + cell.x; + int v = (cell.y >= 0) ? board[idx] : 0; + SDL_Color baseColor = (v > 0 && v < PIECE_COUNT + 1) ? COLORS[v] : SDL_Color{255, 220, 180, 255}; + float cellX = gridX + (cell.x + 0.5f) * finalBlockSize; + float cellY = gridY + (cell.y + 0.85f) * finalBlockSize + rowDropOffsets[cell.y]; + for (int i = 0; i < 4; ++i) { + ImpactSpark spark; + spark.x = cellX + jitter(s_impactRng); + spark.y = cellY + jitter(s_impactRng) * 0.25f; + spark.vx = velX(s_impactRng); + spark.vy = velY(s_impactRng); + spark.lifeMs = lifespan(s_impactRng); + spark.maxLifeMs = spark.lifeMs; + spark.size = sizeDist(s_impactRng); + spark.color = SDL_Color{ + static_cast(std::min(255, baseColor.r + 30)), + static_cast(std::min(255, baseColor.g + 30)), + static_cast(std::min(255, baseColor.b + 30)), + 255 + }; + s_impactSparks.push_back(spark); + } + } + } + for (int y = 0; y < Game::ROWS; ++y) { - float dropOffset = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f); + float dropOffset = rowDropOffsets[y]; for (int x = 0; x < Game::COLS; ++x) { int v = board[y * Game::COLS + x]; if (v > 0) { float bx = gridX + x * finalBlockSize; float by = gridY + y * finalBlockSize + dropOffset; + const int cellIdx = y * Game::COLS + x; + float weight = impactWeight[cellIdx]; + if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) { + float cellSeed = static_cast((x * 37 + y * 61) % 113); + float t = static_cast(nowTicks % 10000) * 0.018f + cellSeed; + float amplitude = 6.0f * impactEased * weight; + float freq = 2.0f + weight * 1.3f; + bx += amplitude * std::sin(t * freq); + by += amplitude * 0.75f * std::cos(t * (freq + 1.1f)); + } drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1); } } } + + if (!s_impactSparks.empty()) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + auto it = s_impactSparks.begin(); + while (it != s_impactSparks.end()) { + ImpactSpark& spark = *it; + spark.vy += 0.00045f * sparkDeltaMs; + spark.x += spark.vx * sparkDeltaMs; + spark.y += spark.vy * sparkDeltaMs; + spark.lifeMs -= sparkDeltaMs; + if (spark.lifeMs <= 0.0f) { + it = s_impactSparks.erase(it); + continue; + } + float lifeRatio = spark.lifeMs / spark.maxLifeMs; + Uint8 alpha = static_cast(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f); + SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha); + SDL_FRect sparkRect{ + spark.x - spark.size * 0.5f, + spark.y - spark.size * 0.5f, + spark.size, + spark.size * 1.4f + }; + SDL_RenderFillRect(renderer, &sparkRect); + ++it; + } + } bool allowActivePieceRender = true; const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled(); diff --git a/src/main.cpp b/src/main.cpp index b77bf4e..a79d599 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -807,7 +807,11 @@ int main(int, char **) loadSoundWithFallback("boom_tetris", "boom_tetris"); loadSoundWithFallback("wonderful", "wonderful"); loadSoundWithFallback("lets_go", "lets_go"); // For level up + loadSoundWithFallback("hard_drop", "hard_drop_001"); + loadSoundWithFallback("new_level", "new_level"); + bool suppressLineVoiceForLevelUp = false; + auto playVoiceCue = [&](int linesCleared) { const std::vector* bank = nullptr; switch (linesCleared) { @@ -835,12 +839,16 @@ int main(int, char **) SoundEffectManager::instance().playSound("clear_line", 1.0f); // Layer a voiced callout based on the number of cleared lines - playVoiceCue(linesCleared); + if (!suppressLineVoiceForLevelUp) { + playVoiceCue(linesCleared); + } + suppressLineVoiceForLevelUp = false; }); game.setLevelUpCallback([&](int newLevel) { - // Play level up sound - SoundEffectManager::instance().playSound("lets_go", 1.0f); // Increased volume + SoundEffectManager::instance().playSound("new_level", 1.0f); + SoundEffectManager::instance().playSound("lets_go", 1.0f); // Existing voice line + suppressLineVoiceForLevelUp = true; }); AppState state = AppState::Loading; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 37ef13e..35e7a0d 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -4,6 +4,7 @@ #include "../gameplay/effects/LineEffect.h" #include "../persistence/Scores.h" #include "../audio/Audio.h" +#include "../audio/SoundEffect.h" #include "../graphics/renderers/GameRenderer.h" #include "../core/Config.h" #include @@ -115,6 +116,7 @@ void PlayingState::handleEvent(const SDL_Event& e) { // Hard drop (space) if (e.key.scancode == SDL_SCANCODE_SPACE) { + SoundEffectManager::instance().playSound("hard_drop", 0.7f); ctx.game->hardDrop(); return; } @@ -128,6 +130,8 @@ void PlayingState::handleEvent(const SDL_Event& e) { void PlayingState::update(double frameMs) { if (!ctx.game) return; + ctx.game->updateVisualEffects(frameMs); + // forward per-frame gameplay updates (gravity, line effects) if (!ctx.game->isPaused()) { ctx.game->tickGravity(frameMs);