diff --git a/CMakeLists.txt b/CMakeLists.txt index af8d82e..2882987 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ set(TETRIS_SOURCES src/graphics/ui/Font.cpp src/graphics/ui/HelpOverlay.cpp src/graphics/renderers/GameRenderer.cpp + src/graphics/renderers/SyncLineRenderer.cpp src/graphics/renderers/UIRenderer.cpp src/audio/Audio.cpp src/gameplay/effects/LineEffect.cpp diff --git a/src/gameplay/effects/LineEffect.cpp b/src/gameplay/effects/LineEffect.cpp index 0dc3570..713718d 100644 --- a/src/gameplay/effects/LineEffect.cpp +++ b/src/gameplay/effects/LineEffect.cpp @@ -188,11 +188,13 @@ void LineEffect::initAudio() { } } -void LineEffect::startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize, int gridCols) { +void LineEffect::startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize, int gridCols, int gapPx, int gapAfterCol) { if (rows.empty()) return; clearingRows = rows; effectGridCols = std::max(1, gridCols); + effectGapPx = std::max(0, gapPx); + effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols); state = AnimationState::FLASH_WHITE; timer = 0.0f; dropProgress = 0.0f; @@ -231,6 +233,9 @@ void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) { const float centerY = gridY + row * blockSize + blockSize * 0.5f; for (int col = 0; col < effectGridCols; ++col) { float centerX = gridX + col * blockSize + blockSize * 0.5f; + if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) { + centerX += static_cast(effectGapPx); + } SDL_Color tint = pickFireColor(); spawnGlowPulse(centerX, centerY, static_cast(blockSize), tint); spawnShardBurst(centerX, centerY, tint); @@ -338,8 +343,12 @@ void LineEffect::updateGlowPulses(float dt) { glowPulses.end()); } -void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) { +void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx, int gapAfterCol) { if (state == AnimationState::IDLE) return; + + // Allow caller to override gap mapping (useful for Coop renderer that inserts a mid-gap). + effectGapPx = std::max(0, gapPx); + effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols); switch (state) { case AnimationState::FLASH_WHITE: @@ -384,10 +393,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) { for (int row : clearingRows) { SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha); + const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0; SDL_FRect flashRect = { static_cast(gridX - 4), static_cast(gridY + row * blockSize - 4), - static_cast(effectGridCols * blockSize + 8), + static_cast(effectGridCols * blockSize + gapW + 8), static_cast(blockSize + 8) }; SDL_RenderFillRect(renderer, &flashRect); diff --git a/src/gameplay/effects/LineEffect.h b/src/gameplay/effects/LineEffect.h index 26fea94..49a5263 100644 --- a/src/gameplay/effects/LineEffect.h +++ b/src/gameplay/effects/LineEffect.h @@ -69,11 +69,11 @@ public: void shutdown(); // Start line clear effect for the specified rows - void startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS); + void startLineClear(const std::vector& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS, int gapPx = 0, int gapAfterCol = 0); // Update and render the effect bool update(float deltaTime); // Returns true if effect is complete - void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize); + void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx = 0, int gapAfterCol = 0); float getRowDropOffset(int row) const; // Audio @@ -121,4 +121,6 @@ private: float dropProgress = 0.0f; int dropBlockSize = 0; int effectGridCols = Game::COLS; + int effectGapPx = 0; + int effectGapAfterCol = 0; }; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index eb6b732..da3b07a 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -1,4 +1,6 @@ #include "GameRenderer.h" + +#include "SyncLineRenderer.h" #include "../../gameplay/core/Game.h" #include "../../gameplay/coop/CoopGame.h" #include "../../app/Fireworks.h" @@ -1852,6 +1854,9 @@ void GameRenderer::renderCoopPlayingState( ) { if (!renderer || !game || !pixelFont) return; + static SyncLineRenderer s_syncLine; + static bool s_lastHadCompletedLines = false; + static Uint32 s_lastCoopTick = SDL_GetTicks(); Uint32 nowTicks = SDL_GetTicks(); float deltaMs = static_cast(nowTicks - s_lastCoopTick); @@ -1860,6 +1865,9 @@ void GameRenderer::renderCoopPlayingState( deltaMs = 16.0f; } + const float deltaSeconds = std::clamp(deltaMs / 1000.0f, 0.0f, 0.033f); + s_syncLine.Update(deltaSeconds); + 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{}; @@ -1889,15 +1897,19 @@ void GameRenderer::renderCoopPlayingState( SDL_RenderFillRect(renderer, &fr); }; + static constexpr float COOP_GAP_PX = 10.0f; + 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 usableGridWidth = std::max(0.0f, availableWidth - COOP_GAP_PX); + const float maxBlockSizeW = usableGridWidth / 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 HALF_W = 10.0f * finalBlockSize; + const float GRID_W = CoopGame::COLS * finalBlockSize + COOP_GAP_PX; const float GRID_H = CoopGame::ROWS * finalBlockSize; const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H; @@ -1923,7 +1935,7 @@ void GameRenderer::renderCoopPlayingState( // 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(gridX), static_cast(gridY), static_cast(finalBlockSize), CoopGame::COLS); + lineEffect->startLineClear(completedLines, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize), CoopGame::COLS, static_cast(COOP_GAP_PX), 10); if (completedLines.size() == 4) { AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f); } @@ -1935,28 +1947,39 @@ void GameRenderer::renderCoopPlayingState( rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f); } - // Grid backdrop and border + // Grid backdrop and border (one border around both halves) drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); + // Background for left+right halves drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); + // Gap background (slightly darker so the 10px separation reads clearly) + drawRectWithOffset(gridX + HALF_W - contentOffsetX, gridY - contentOffsetY, COOP_GAP_PX, GRID_H, {12, 14, 18, 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); + // Sync divider line centered in the gap between halves. + const float dividerCenterX = gridX + HALF_W + (COOP_GAP_PX * 0.5f); + s_syncLine.SetRect(SDL_FRect{ dividerCenterX - 2.0f, gridY, 4.0f, GRID_H }); - // Grid lines + auto cellX = [&](int col) -> float { + float x = gridX + col * finalBlockSize; + if (col >= 10) { + x += COOP_GAP_PX; + } + return x; + }; + + // Grid lines (draw per-half so the gap is clean) SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); - for (int x = 1; x < CoopGame::COLS; ++x) { + for (int x = 1; x < 10; ++x) { float lineX = gridX + x * finalBlockSize; SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); } + for (int x = 1; x < 10; ++x) { + float lineX = gridX + HALF_W + COOP_GAP_PX + 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); + SDL_RenderLine(renderer, gridX, lineY, gridX + HALF_W, lineY); + SDL_RenderLine(renderer, gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY); } // In-grid 3D starfield + ambient sparkles (match classic feel, per-half) @@ -1970,9 +1993,9 @@ void GameRenderer::renderCoopPlayingState( static float s_leftSparkleSpawnAcc = 0.0f; static float s_rightSparkleSpawnAcc = 0.0f; - float halfW = GRID_W * 0.5f; + float halfW = HALF_W; const float leftGridX = gridX; - const float rightGridX = gridX + halfW; + const float rightGridX = gridX + HALF_W + COOP_GAP_PX; Uint32 sparkNow = nowTicks; float sparkDeltaMs = static_cast(sparkNow - s_lastCoopSparkTick); @@ -2185,24 +2208,57 @@ void GameRenderer::renderCoopPlayingState( // 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(); + + bool leftReady = false; + bool rightReady = false; + bool synced = false; + for (int y = 0; y < CoopGame::ROWS; ++y) { const auto& rs = rowStates[y]; float rowY = gridY + y * finalBlockSize; + if (rs.leftFull && rs.rightFull) { + synced = true; + } else { + leftReady = leftReady || (rs.leftFull && !rs.rightFull); + rightReady = rightReady || (rs.rightFull && !rs.leftFull); + } + if (rs.leftFull && rs.rightFull) { SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45); - SDL_FRect fr{gridX, rowY, GRID_W, finalBlockSize}; - SDL_RenderFillRect(renderer, &fr); + SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize}; + SDL_RenderFillRect(renderer, &frL); + SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize}; + SDL_RenderFillRect(renderer, &frR); } 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; + float w = HALF_W; + float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX); SDL_FRect fr{x, rowY, w, finalBlockSize}; SDL_RenderFillRect(renderer, &fr); } } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + // Trigger a brief flash exactly when cooperative lines are actually cleared: + // `completedLines` remains populated during the LineEffect, then becomes empty + // immediately after `CoopGame::clearCompletedLines()` is invoked. + const bool hasCompletedLines = game->hasCompletedLines(); + if (s_lastHadCompletedLines && !hasCompletedLines) { + s_syncLine.TriggerClearFlash(); + } + s_lastHadCompletedLines = hasCompletedLines; + + if (synced) { + s_syncLine.SetState(SyncState::Synced); + } else if (leftReady) { + s_syncLine.SetState(SyncState::LeftReady); + } else if (rightReady) { + s_syncLine.SetState(SyncState::RightReady); + } else { + s_syncLine.SetState(SyncState::Idle); + } + // Hard-drop impact shake (match classic feel) float impactStrength = 0.0f; float impactEased = 0.0f; @@ -2243,7 +2299,7 @@ void GameRenderer::renderCoopPlayingState( 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 px = cellX(x); float py = gridY + y * finalBlockSize + dropOffset; const int cellIdx = y * CoopGame::COLS + x; @@ -2398,7 +2454,7 @@ void GameRenderer::renderCoopPlayingState( // cases like vertical I where some blocks are already visible at spawn. const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0; - const float baseX = gridX + static_cast(livePiece.x) * sf.tileSize + offsets.first; + const float baseX = cellX(livePiece.x) + offsets.first; float baseY = 0.0f; if (pinToFirstVisibleRow) { // Keep the piece visible (topmost filled cell at row 0), but also @@ -2439,7 +2495,7 @@ void GameRenderer::renderCoopPlayingState( 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 px = cellX(pxIdx) + offsets.first; float py = gridY + (float)pyIdx * finalBlockSize + offsets.second; if (isGhost) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); @@ -2505,15 +2561,19 @@ void GameRenderer::renderCoopPlayingState( // Draw line clearing effects above pieces (matches single-player) if (lineEffect && lineEffect->isActive()) { - lineEffect->render(renderer, blocksTex, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize)); + lineEffect->render(renderer, blocksTex, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize), static_cast(COOP_GAP_PX), 10); } + // Render the SYNC divider last so it stays visible above effects/blocks. + s_syncLine.Render(renderer); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + // Next panels (two) const float nextPanelPad = 12.0f; - const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f; + const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f; const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f; float nextLeftX = gridX + finalBlockSize; - float nextRightX = gridX + GRID_W - finalBlockSize - nextPanelW; + float nextRightX = gridX + HALF_W + COOP_GAP_PX + (HALF_W - finalBlockSize - nextPanelW); float nextY = contentStartY + contentOffsetY; auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) { diff --git a/src/graphics/renderers/SyncLineRenderer.cpp b/src/graphics/renderers/SyncLineRenderer.cpp new file mode 100644 index 0000000..675a735 --- /dev/null +++ b/src/graphics/renderers/SyncLineRenderer.cpp @@ -0,0 +1,79 @@ +#include "SyncLineRenderer.h" + +#include + +SyncLineRenderer::SyncLineRenderer() + : m_state(SyncState::Idle), + m_flashTimer(0.0f), + m_time(0.0f) {} + +void SyncLineRenderer::SetRect(const SDL_FRect& rect) { + m_rect = rect; +} + +void SyncLineRenderer::SetState(SyncState state) { + if (state != SyncState::ClearFlash) { + m_state = state; + } +} + +void SyncLineRenderer::TriggerClearFlash() { + m_state = SyncState::ClearFlash; + m_flashTimer = FLASH_DURATION; +} + +void SyncLineRenderer::Update(float deltaTime) { + m_time += deltaTime; + + if (m_state == SyncState::ClearFlash) { + m_flashTimer -= deltaTime; + if (m_flashTimer <= 0.0f) { + m_state = SyncState::Idle; + m_flashTimer = 0.0f; + } + } +} + +SDL_Color SyncLineRenderer::GetBaseColor() const { + switch (m_state) { + case SyncState::LeftReady: + case SyncState::RightReady: + return SDL_Color{255, 220, 100, 235}; + + case SyncState::Synced: + return SDL_Color{100, 255, 120, 240}; + + case SyncState::ClearFlash: + return SDL_Color{255, 255, 255, 255}; + + default: + return SDL_Color{80, 180, 255, 235}; + } +} + +void SyncLineRenderer::Render(SDL_Renderer* renderer) { + if (!renderer) { + return; + } + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + SDL_Color color = GetBaseColor(); + + if (m_state == SyncState::Synced) { + float pulse = 0.5f + 0.5f * std::sinf(m_time * 6.0f); + color.a = static_cast(180.0f + pulse * 75.0f); + } + + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + SDL_RenderFillRect(renderer, &m_rect); + + if (m_state == SyncState::ClearFlash) { + SDL_FRect glow = m_rect; + glow.x -= 3; + glow.w += 6; + + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 180); + SDL_RenderFillRect(renderer, &glow); + } +} diff --git a/src/graphics/renderers/SyncLineRenderer.h b/src/graphics/renderers/SyncLineRenderer.h new file mode 100644 index 0000000..3d529d2 --- /dev/null +++ b/src/graphics/renderers/SyncLineRenderer.h @@ -0,0 +1,33 @@ +#pragma once +#include + +enum class SyncState { + Idle, + LeftReady, + RightReady, + Synced, + ClearFlash +}; + +class SyncLineRenderer { +public: + SyncLineRenderer(); + + void SetRect(const SDL_FRect& rect); + void SetState(SyncState state); + void TriggerClearFlash(); + + void Update(float deltaTime); + void Render(SDL_Renderer* renderer); + +private: + SDL_FRect m_rect{}; + SyncState m_state; + + float m_flashTimer; + float m_time; + + static constexpr float FLASH_DURATION = 0.15f; + + SDL_Color GetBaseColor() const; +};