#include "GameRenderer.h" #include "../../gameplay/core/Game.h" #include "../ui/Font.h" #include "../../gameplay/effects/LineEffect.h" #include #include #include #include #include #include #include #include #include "../../core/Settings.h" #include "../../graphics/effects/Starfield3D.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}; }; struct Sparkle { 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}; float pulse = 0.0f; }; struct ActivePieceSmoothState { uint64_t sequence = 0; float visualX = 0.0f; bool initialized = false; }; ActivePieceSmoothState s_activePieceSmooth; Starfield3D s_inGridStarfield; bool s_starfieldInitialized = false; std::vector s_sparkles; float s_sparkleSpawnAcc = 0.0f; } struct TransportEffectState { bool active = false; Uint32 startTick = 0; float durationMs = 600.0f; Game::Piece piece; float startX = 0.0f; // pixel origin of piece local (0,0) float startY = 0.0f; float targetX = 0.0f; float targetY = 0.0f; float tileSize = 24.0f; // Next preview that should fade in after the transfer completes Game::Piece nextPiece; float nextPreviewX = 0.0f; float nextPreviewY = 0.0f; }; static TransportEffectState s_transport; static float smoothstep(float t) { t = std::clamp(t, 0.0f, 1.0f); return t * t * (3.0f - 2.0f * t); } void GameRenderer::startTransportEffect(const Game::Piece& piece, float startX, float startY, float targetX, float targetY, float tileSize, float durationSeconds) { s_transport.active = true; s_transport.startTick = SDL_GetTicks(); s_transport.durationMs = std::max(8.0f, durationSeconds * 1000.0f); s_transport.piece = piece; s_transport.startX = startX; s_transport.startY = startY; s_transport.targetX = targetX; s_transport.targetY = targetY; s_transport.tileSize = tileSize; } void GameRenderer::startTransportEffectForGame(Game* game, SDL_Texture* blocksTex, float logicalW, float logicalH, float logicalScale, float winW, float winH, float durationSeconds) { if (!game) return; // Recompute layout exactly like renderPlayingState so coordinates match 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_PIECE_HEIGHT = 120.0f; const float BOTTOM_MARGIN = 60.0f; 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; const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2); const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT; const float maxBlockSizeW = availableWidth / Game::COLS; const float maxBlockSizeH = availableHeight / Game::ROWS; const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH); const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f)); const float GRID_W = Game::COLS * finalBlockSize; const float GRID_H = Game::ROWS * finalBlockSize; const float totalContentHeight = NEXT_PIECE_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 gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX; const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY; // Compute next panel placement (same as renderPlayingState) const float NEXT_PANEL_WIDTH = GRID_W - finalBlockSize * 2.0f; const float NEXT_PANEL_HEIGHT = finalBlockSize * 3.0f; const float NEXT_PANEL_X = gridX + finalBlockSize; // Move NEXT panel a bit higher so it visually separates from the grid const float NEXT_PANEL_Y = gridY - NEXT_PANEL_HEIGHT - 12.0f; // We'll animate the piece that is now current (the newly spawned piece) const Game::Piece piece = game->current(); // Determine piece bounds in its 4x4 to center into preview area int minCx = 4, maxCx = -1, minCy = 4, maxCy = -1; for (int cy = 0; cy < 4; ++cy) for (int cx = 0; cx < 4; ++cx) if (Game::cellFilled(piece, cx, cy)) { minCx = std::min(minCx, cx); maxCx = std::max(maxCx, cx); minCy = std::min(minCy, cy); maxCy = std::max(maxCy, cy); } if (maxCx < minCx) { minCx = 0; maxCx = 0; } if (maxCy < minCy) { minCy = 0; maxCy = 0; } const float labelReserve = finalBlockSize * 0.9f; const float previewTop = NEXT_PANEL_Y + std::min(labelReserve, NEXT_PANEL_HEIGHT * 0.45f); const float previewBottom = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT - finalBlockSize * 0.25f; const float previewCenterY = (previewTop + previewBottom) * 0.5f; const float previewCenterX = std::round(NEXT_PANEL_X + NEXT_PANEL_WIDTH * 0.5f); const float pieceWidth = static_cast(maxCx - minCx + 1) * finalBlockSize; const float pieceHeight = static_cast(maxCy - minCy + 1) * finalBlockSize; float startX = previewCenterX - pieceWidth * 0.5f - static_cast(minCx) * finalBlockSize; float startY = previewCenterY - pieceHeight * 0.5f - static_cast(minCy) * finalBlockSize; // Snap to grid columns float gridOriginX = NEXT_PANEL_X - finalBlockSize; float rel = startX - gridOriginX; float nearestTile = std::round(rel / finalBlockSize); startX = gridOriginX + nearestTile * finalBlockSize; startY = std::round(startY); // Target is the current piece's grid position float targetX = gridX + piece.x * finalBlockSize; float targetY = gridY + piece.y * finalBlockSize; // Also compute where the new NEXT preview (game->next()) will be drawn so we can fade it in later const Game::Piece nextPiece = game->next(); // Compute next preview placement (center within NEXT panel) int nMinCx = 4, nMaxCx = -1, nMinCy = 4, nMaxCy = -1; for (int cy = 0; cy < 4; ++cy) for (int cx = 0; cx < 4; ++cx) if (Game::cellFilled(nextPiece, cx, cy)) { nMinCx = std::min(nMinCx, cx); nMaxCx = std::max(nMaxCx, cx); nMinCy = std::min(nMinCy, cy); nMaxCy = std::max(nMaxCy, cy); } if (nMaxCx < nMinCx) { nMinCx = 0; nMaxCx = 0; } if (nMaxCy < nMinCy) { nMinCy = 0; nMaxCy = 0; } const float previewTop2 = NEXT_PANEL_Y + std::min(finalBlockSize * 0.9f, NEXT_PANEL_HEIGHT * 0.45f); const float previewBottom2 = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT - finalBlockSize * 0.25f; const float previewCenterY2 = (previewTop2 + previewBottom2) * 0.5f; const float previewCenterX2 = std::round(NEXT_PANEL_X + NEXT_PANEL_WIDTH * 0.5f); const float pieceWidth2 = static_cast(nMaxCx - nMinCx + 1) * finalBlockSize; const float pieceHeight2 = static_cast(nMaxCy - nMinCy + 1) * finalBlockSize; float nextPreviewX = previewCenterX2 - pieceWidth2 * 0.5f - static_cast(nMinCx) * finalBlockSize; float nextPreviewY = previewCenterY2 - pieceHeight2 * 0.5f - static_cast(nMinCy) * finalBlockSize; // Snap to grid columns float gridOriginX2 = NEXT_PANEL_X - finalBlockSize; float rel2 = nextPreviewX - gridOriginX2; float nearestTile2 = std::round(rel2 / finalBlockSize); nextPreviewX = gridOriginX2 + nearestTile2 * finalBlockSize; nextPreviewY = std::round(nextPreviewY); // Initialize transport state to perform fades: preview fade-out -> grid fade-in -> next preview fade-in s_transport.active = true; s_transport.startTick = SDL_GetTicks(); s_transport.durationMs = std::max(100.0f, durationSeconds * 1000.0f); s_transport.piece = piece; s_transport.startX = startX; s_transport.startY = startY; s_transport.targetX = targetX; s_transport.targetY = targetY; s_transport.tileSize = finalBlockSize; // Store next preview piece and its pixel origin so we can fade it in later s_transport.nextPiece = nextPiece; s_transport.nextPreviewX = nextPreviewX; s_transport.nextPreviewY = nextPreviewY; } bool GameRenderer::isTransportActive() { return s_transport.active; } // Draw the ongoing transport effect; called every frame from renderPlayingState static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTex) { if (!s_transport.active) return; Uint32 now = SDL_GetTicks(); float elapsed = static_cast(now - s_transport.startTick); float total = s_transport.durationMs; if (total <= 0.0f) total = 1.0f; // Simultaneous cross-fade: as the NEXT preview fades out, the piece fades into the grid // and the new NEXT preview fades in — all driven by the same normalized t in [0,1]. float t = std::clamp(elapsed / total, 0.0f, 1.0f); Uint8 previewAlpha = static_cast(std::lround(255.0f * (1.0f - t))); Uint8 gridAlpha = static_cast(std::lround(255.0f * t)); Uint8 nextAlpha = gridAlpha; // fade new NEXT preview in at same rate as grid // Draw preview fade-out if (previewAlpha > 0) { if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, previewAlpha); for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(s_transport.piece, cx, cy)) continue; float px = s_transport.startX + static_cast(cx) * s_transport.tileSize; float py = s_transport.startY + static_cast(cy) * s_transport.tileSize; GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type); } } if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); } // Draw grid fade-in (same intensity as next preview fade-in) if (gridAlpha > 0) { if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, gridAlpha); for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(s_transport.piece, cx, cy)) continue; float gx = s_transport.targetX + static_cast(cx) * s_transport.tileSize; float gy = s_transport.targetY + static_cast(cy) * s_transport.tileSize; GameRenderer::drawBlockTexturePublic(renderer, blocksTex, gx, gy, s_transport.tileSize, s_transport.piece.type); } } if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); } // Draw new NEXT preview fade-in (simultaneous) if (nextAlpha > 0) { if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, nextAlpha); for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(s_transport.nextPiece, cx, cy)) continue; float nx = s_transport.nextPreviewX + static_cast(cx) * s_transport.tileSize; float ny = s_transport.nextPreviewY + static_cast(cy) * s_transport.tileSize; GameRenderer::drawBlockTexturePublic(renderer, blocksTex, nx, ny, s_transport.tileSize, s_transport.nextPiece.type); } } if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); } if (t >= 1.0f) { s_transport.active = false; } } // Color constants (copied from main.cpp) static const SDL_Color COLORS[] = { {0, 0, 0, 255}, // 0: BLACK (empty) {0, 255, 255, 255}, // 1: I-piece - cyan {255, 255, 0, 255}, // 2: O-piece - yellow {128, 0, 128, 255}, // 3: T-piece - purple {0, 255, 0, 255}, // 4: S-piece - green {255, 0, 0, 255}, // 5: Z-piece - red {0, 0, 255, 255}, // 6: J-piece - blue {255, 165, 0, 255} // 7: L-piece - orange }; void GameRenderer::drawRect(SDL_Renderer* renderer, 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, y, w, h}; SDL_RenderFillRect(renderer, &fr); } 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 SDL_Color color = (blockType >= 0 && blockType < PIECE_COUNT) ? COLORS[blockType + 1] : SDL_Color{128, 128, 128, 255}; drawRect(renderer, x, y, size-1, size-1, color); return; } // JavaScript uses: sx = type * spriteSize, sy = 0, with 2px padding // Each sprite is 90px wide in the horizontal sprite sheet const int SPRITE_SIZE = 90; float srcX = blockType * SPRITE_SIZE + 2; // Add 2px padding like JS float srcY = 2; // Add 2px padding from top like JS float srcW = SPRITE_SIZE - 4; // Subtract 4px total padding like JS float srcH = SPRITE_SIZE - 4; // Subtract 4px total padding like JS SDL_FRect srcRect = {srcX, srcY, srcW, srcH}; SDL_FRect dstRect = {x, y, size, size}; SDL_RenderTexture(renderer, blocksTex, &srcRect, &dstRect); } void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost, float pixelOffsetX, float pixelOffsetY) { if (piece.type >= PIECE_COUNT) return; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (Game::cellFilled(piece, cx, cy)) { float px = ox + (piece.x + cx) * tileSize + pixelOffsetX; float py = oy + (piece.y + cy) * tileSize + pixelOffsetY; if (isGhost) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); // Draw ghost piece as barely visible gray outline SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20); // Very faint gray SDL_FRect rect = {px + 2, py + 2, tileSize - 4, tileSize - 4}; SDL_RenderFillRect(renderer, &rect); // Draw thin gray border SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30); SDL_FRect border = {px + 1, py + 1, tileSize - 2, tileSize - 2}; SDL_RenderRect(renderer, &border); } else { drawBlockTexture(renderer, blocksTex, px, py, tileSize, piece.type); } } } } } void GameRenderer::drawBlockTexturePublic(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) { // Forward to the private helper drawBlockTexture(renderer, blocksTex, x, y, size, blockType); } void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) { if (pieceType >= PIECE_COUNT) return; // Use the first rotation (index 0) for preview Game::Piece previewPiece; previewPiece.type = pieceType; previewPiece.rot = 0; previewPiece.x = 0; previewPiece.y = 0; // Determine occupied bounds within 4x4 and center inside the 4x4 preview area int minCx = 4, maxCx = -1, minCy = 4, maxCy = -1; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (Game::cellFilled(previewPiece, cx, cy)) { minCx = std::min(minCx, cx); maxCx = std::max(maxCx, cx); minCy = std::min(minCy, cy); maxCy = std::max(maxCy, cy); } } } if (maxCx < minCx) { minCx = 0; maxCx = 0; } if (maxCy < minCy) { minCy = 0; maxCy = 0; } float areaW = 4.0f * tileSize; float areaH = 4.0f * tileSize; float pieceW = static_cast(maxCx - minCx + 1) * tileSize; float pieceH = static_cast(maxCy - minCy + 1) * tileSize; float offsetX = (areaW - pieceW) * 0.5f - static_cast(minCx) * tileSize; float offsetY = (areaH - pieceH) * 0.5f - static_cast(minCy) * tileSize; offsetX = std::round(offsetX); offsetY = std::round(offsetY); // Use semi-transparent alpha for preview blocks Uint8 previewAlpha = 180; if (blocksTex) { SDL_SetTextureAlphaMod(blocksTex, previewAlpha); } for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (Game::cellFilled(previewPiece, cx, cy)) { float px = x + offsetX + cx * tileSize; float py = y + offsetY + cy * tileSize; GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, tileSize, pieceType); } } } // Reset alpha if (blocksTex) { SDL_SetTextureAlphaMod(blocksTex, 255); } } void GameRenderer::renderNextPanel( SDL_Renderer* renderer, FontAtlas* pixelFont, SDL_Texture* blocksTex, const Game::Piece& nextPiece, float panelX, float panelY, float panelW, float panelH, float tileSize ) { if (!renderer || !pixelFont) { return; } const SDL_Color gridBorderColor{60, 80, 160, 255}; // matches main grid outline const SDL_Color bayColor{8, 12, 24, 235}; const SDL_Color bayOutline{25, 62, 86, 220}; const SDL_Color labelColor{255, 220, 0, 255}; SDL_FRect bayRect{panelX, panelY, panelW, panelH}; SDL_SetRenderDrawColor(renderer, bayColor.r, bayColor.g, bayColor.b, bayColor.a); SDL_RenderFillRect(renderer, &bayRect); SDL_FRect thinOutline{panelX - 1.0f, panelY - 1.0f, panelW + 2.0f, panelH + 2.0f}; auto drawOutlineNoBottom = [&](const SDL_FRect& rect, SDL_Color color) { SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); const float left = rect.x; const float top = rect.y; const float right = rect.x + rect.w; const float bottom = rect.y + rect.h; SDL_RenderLine(renderer, left, top, right, top); // top edge SDL_RenderLine(renderer, left, top, left, bottom); // left edge SDL_RenderLine(renderer, right, top, right, bottom); // right edge }; drawOutlineNoBottom(thinOutline, gridBorderColor); drawOutlineNoBottom(bayRect, bayOutline); const float labelPad = tileSize * 0.25f; pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor); if (nextPiece.type >= PIECE_COUNT) { return; } // Determine the occupied bounds of the tetromino within its 4x4 local grid. int minCx = 4; int maxCx = -1; int minCy = 4; int maxCy = -1; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(nextPiece, cx, cy)) { continue; } minCx = std::min(minCx, cx); maxCx = std::max(maxCx, cx); minCy = std::min(minCy, cy); maxCy = std::max(maxCy, cy); } } if (maxCx < minCx || maxCy < minCy) { return; } // Reserve a little headroom for the NEXT label, then center the piece in screen-space. const float labelReserve = tileSize * 0.9f; const float previewTop = panelY + std::min(labelReserve, panelH * 0.45f); const float previewBottom = panelY + panelH - tileSize * 0.25f; const float previewCenterY = (previewTop + previewBottom) * 0.5f; const float previewCenterX = std::round(panelX + panelW * 0.5f); const float pieceWidth = static_cast(maxCx - minCx + 1) * tileSize; const float pieceHeight = static_cast(maxCy - minCy + 1) * tileSize; // Center piece so its local cells fall exactly on grid-aligned pixel columns float startX = previewCenterX - pieceWidth * 0.5f - static_cast(minCx) * tileSize; float startY = previewCenterY - pieceHeight * 0.5f - static_cast(minCy) * tileSize; // Snap horizontal position to the playfield's tile grid so preview cells align exactly // with the main grid columns. `panelX` was computed as `gridX + tileSize` in caller, // so derive grid origin as `panelX - tileSize`. float gridOriginX = panelX - tileSize; float rel = startX - gridOriginX; float nearestTile = std::round(rel / tileSize); startX = gridOriginX + nearestTile * tileSize; // Round Y to pixel to avoid subpixel artifacts startY = std::round(startY); // If a transfer fade is active, the preview cells will be drawn by the // transport effect (with fade). Skip drawing the normal preview in that case. if (!s_transport.active) { for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(nextPiece, cx, cy)) { continue; } const float px = startX + static_cast(cx) * tileSize; const float py = startY + static_cast(cy) * tileSize; GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, tileSize, nextPiece.type); } } } } void GameRenderer::renderPlayingState( SDL_Renderer* renderer, Game* game, FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, float logicalW, float logicalH, float logicalScale, float winW, 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; float contentW = logicalW * contentScale; float contentH = logicalH * contentScale; float contentOffsetX = (winW - contentW) * 0.5f / contentScale; float contentOffsetY = (winH - contentH) * 0.5f / contentScale; // Helper lambda for drawing rectangles with content offset 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); }; // Responsive layout that scales with window size while maintaining margins 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_PIECE_HEIGHT = 120.0f; const float BOTTOM_MARGIN = 60.0f; // Calculate layout dimensions const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2); const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT; const float maxBlockSizeW = availableWidth / Game::COLS; const float maxBlockSizeH = availableHeight / Game::ROWS; const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH); const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f)); const float GRID_W = Game::COLS * finalBlockSize; const float GRID_H = Game::ROWS * finalBlockSize; // Calculate positions const float totalContentHeight = NEXT_PIECE_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_PIECE_HEIGHT + contentOffsetY; const float statsY = gridY; const float statsW = PANEL_WIDTH; const float statsH = GRID_H; // Next piece preview position // Make NEXT panel span the inner area of the grid with a 1-cell margin on both sides const float NEXT_PANEL_WIDTH = GRID_W - finalBlockSize * 2.0f; // leave 1 cell on left and right const float NEXT_PANEL_HEIGHT = finalBlockSize * 3.0f; const float NEXT_PANEL_X = gridX + finalBlockSize; // align panel so there's exactly one cell margin // Move NEXT panel a bit higher so it visually separates from the grid const float NEXT_PANEL_Y = gridY - NEXT_PANEL_HEIGHT - 12.0f; // nudge up ~12px // Handle line clearing effects if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) { auto completedLines = game->getCompletedLines(); lineEffect->startLineClear(completedLines, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize)); } // Draw game grid border drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); // Draw a 1px blue border around grid but omit the top horizontal so the NEXT panel // can visually join seamlessly. We'll draw left, right and bottom bands manually. { SDL_Color blue{60, 80, 160, 255}; // left vertical band (1px wide) drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - contentOffsetY, 1.0f, GRID_H, blue); // right vertical band (1px wide) drawRectWithOffset(gridX + GRID_W - contentOffsetX, gridY - contentOffsetY, 1.0f, GRID_H, blue); // bottom horizontal band (1px high) drawRectWithOffset(gridX - 1 - contentOffsetX, gridY + GRID_H - contentOffsetY, GRID_W + 2.0f, 1.0f, blue); } drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); // Draw stats panel backdrop using the same art as the score panel const float blocksPanelPadLeft = 34.0f; const float blocksPanelPadRight = 10.0f; const float blocksPanelPadY = 26.0f; SDL_FRect blocksPanelBg{ statsX - blocksPanelPadLeft, gridY - blocksPanelPadY, statsW + blocksPanelPadLeft + blocksPanelPadRight, GRID_H + blocksPanelPadY * 2.0f }; if (statisticsPanelTex) { // Use the dedicated statistics panel image for the left panel when available. // Preserve aspect ratio by scaling to the panel width and center/crop vertically if needed. float texWf = 0.0f, texHf = 0.0f; if (SDL_GetTextureSize(statisticsPanelTex, &texWf, &texHf) == 0) { const float destW = blocksPanelBg.w; const float destH = blocksPanelBg.h; const float scale = destW / texWf; const float scaledH = texHf * scale; if (scaledH <= destH) { // Fits vertically: draw full texture centered vertically SDL_FRect srcF{0.0f, 0.0f, texWf, texHf}; SDL_RenderTexture(renderer, statisticsPanelTex, &srcF, &blocksPanelBg); } else { // Texture is taller when scaled to width: crop vertically from texture float srcHf = destH / scale; float srcYf = std::max(0.0f, (texHf - srcHf) * 0.5f); SDL_FRect srcF{0.0f, srcYf, texWf, srcHf}; SDL_RenderTexture(renderer, statisticsPanelTex, &srcF, &blocksPanelBg); } } else { // Fallback: render entire texture if query failed SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &blocksPanelBg); } } else if (scorePanelTex) { SDL_RenderTexture(renderer, scorePanelTex, nullptr, &blocksPanelBg); } else { SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); SDL_RenderFillRect(renderer, &blocksPanelBg); } // Draw grid lines SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); for (int x = 1; x < Game::COLS; ++x) { float lineX = gridX + x * finalBlockSize; SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); } for (int y = 1; y < Game::ROWS; ++y) { float lineY = gridY + y * finalBlockSize; SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY); } if (!s_starfieldInitialized) { s_inGridStarfield.init(static_cast(GRID_W), static_cast(GRID_H), 180); s_starfieldInitialized = true; } else { s_inGridStarfield.resize(static_cast(GRID_W), static_cast(GRID_H)); } const float deltaSeconds = std::clamp(static_cast(sparkDeltaMs) / 1000.0f, 0.0f, 0.033f); s_inGridStarfield.update(deltaSeconds); bool appliedMagnet = false; if (game) { const Game::Piece& activePiece = game->current(); const int pieceType = static_cast(activePiece.type); if (pieceType >= 0 && pieceType < PIECE_COUNT) { float sumLocalX = 0.0f; float sumLocalY = 0.0f; int filledCells = 0; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(activePiece, cx, cy)) { continue; } sumLocalX += (activePiece.x + cx + 0.5f) * finalBlockSize; sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize; ++filledCells; } } if (filledCells > 0) { float magnetLocalX = sumLocalX / static_cast(filledCells); float magnetLocalY = sumLocalY / static_cast(filledCells); magnetLocalX = std::clamp(magnetLocalX, 0.0f, GRID_W); magnetLocalY = std::clamp(magnetLocalY, 0.0f, GRID_H); const float magnetStrength = finalBlockSize * 2.2f; s_inGridStarfield.setMagnetTarget(magnetLocalX, magnetLocalY, magnetStrength); appliedMagnet = true; } } } if (!appliedMagnet) { s_inGridStarfield.clearMagnetTarget(); } SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE; SDL_GetRenderDrawBlendMode(renderer, &oldBlend); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); s_inGridStarfield.draw(renderer, gridX, gridY, 0.22f, true); // Update and spawn ambient sparkles inside/around the grid // Use the same RNG and timing values used for impact sparks if (!game->isPaused()) { // Spawn rate: ~10 sparks/sec total (adjustable) const float spawnInterval = 0.08f; // seconds s_sparkleSpawnAcc += deltaSeconds; while (s_sparkleSpawnAcc >= spawnInterval) { s_sparkleSpawnAcc -= spawnInterval; Sparkle s; // Choose spawn area: near active piece magnet if present, otherwise along top/border bool spawnNearPiece = appliedMagnet && (std::uniform_real_distribution(0.0f,1.0f)(s_impactRng) > 0.35f); float sx = 0.0f, sy = 0.0f; if (spawnNearPiece) { // Use starfield magnet target if set (approx center of active piece) // Random jitter around magnet float jitterX = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_impactRng); float jitterY = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_impactRng); // s_inGridStarfield stores magnet in local coords when used; approximate from magnet calculations above // We'll center near grid center if magnet not available sx = std::clamp(GRID_W * 0.5f + jitterX, -finalBlockSize * 2.0f, GRID_W + finalBlockSize * 2.0f); sy = std::clamp(GRID_H * 0.4f + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f); } else { // Spawn along border: choose side and position float side = std::uniform_real_distribution(0.0f, 1.0f)(s_impactRng); // Border band width (how far outside the grid sparks can appear) const float borderBand = std::max(12.0f, finalBlockSize * 1.0f); if (side < 0.2f) { // left (outside) sx = std::uniform_real_distribution(-borderBand, 0.0f)(s_impactRng); sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_impactRng); } else if (side < 0.4f) { // right (outside) sx = std::uniform_real_distribution(GRID_W, GRID_W + borderBand)(s_impactRng); sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_impactRng); } else if (side < 0.6f) { // top (outside) sx = std::uniform_real_distribution(-borderBand, GRID_W + borderBand)(s_impactRng); sy = std::uniform_real_distribution(-borderBand, 0.0f)(s_impactRng); } else if (side < 0.9f) { // top/inside border area sx = std::uniform_real_distribution(0.0f, GRID_W)(s_impactRng); sy = std::uniform_real_distribution(0.0f, finalBlockSize * 2.0f)(s_impactRng); } else { // bottom (outside) sx = std::uniform_real_distribution(-borderBand, GRID_W + borderBand)(s_impactRng); sy = std::uniform_real_distribution(GRID_H, GRID_H + borderBand)(s_impactRng); } } s.x = sx; s.y = sy; float speed = std::uniform_real_distribution(10.0f, 60.0f)(s_impactRng); float ang = std::uniform_real_distribution(-3.14159f, 3.14159f)(s_impactRng); s.vx = std::cos(ang) * speed; s.vy = std::sin(ang) * speed * 0.25f; // slower vertical movement s.maxLifeMs = std::uniform_real_distribution(350.0f, 900.0f)(s_impactRng); s.lifeMs = s.maxLifeMs; s.size = std::uniform_real_distribution(1.5f, 5.0f)(s_impactRng); // Soft color range towards warm/cyan tints if (std::uniform_real_distribution(0.0f,1.0f)(s_impactRng) < 0.5f) { s.color = SDL_Color{255, 230, 180, 255}; } else { s.color = SDL_Color{180, 220, 255, 255}; } s.pulse = std::uniform_real_distribution(0.0f, 6.28f)(s_impactRng); s_sparkles.push_back(s); } } // Update and draw sparkles if (!s_sparkles.empty()) { auto it = s_sparkles.begin(); while (it != s_sparkles.end()) { Sparkle &sp = *it; sp.lifeMs -= sparkDeltaMs; if (sp.lifeMs <= 0.0f) { // On expiration, spawn a small burst of ImpactSparks (smaller boxes) const int burstCount = std::uniform_int_distribution(4, 8)(s_impactRng); for (int bi = 0; bi < burstCount; ++bi) { ImpactSpark ps; // Position in absolute coords (same space as other impact sparks) ps.x = gridX + sp.x + std::uniform_real_distribution(-2.0f, 2.0f)(s_impactRng); ps.y = gridY + sp.y + std::uniform_real_distribution(-2.0f, 2.0f)(s_impactRng); float ang = std::uniform_real_distribution(0.0f, 6.2831853f)(s_impactRng); float speed = std::uniform_real_distribution(10.0f, 120.0f)(s_impactRng); ps.vx = std::cos(ang) * speed; ps.vy = std::sin(ang) * speed * 0.8f; ps.maxLifeMs = std::uniform_real_distribution(220.0f, 500.0f)(s_impactRng); ps.lifeMs = ps.maxLifeMs; ps.size = std::max(1.0f, sp.size * 0.5f); ps.color = sp.color; s_impactSparks.push_back(ps); } it = s_sparkles.erase(it); continue; } float lifeRatio = sp.lifeMs / sp.maxLifeMs; // simple motion sp.x += sp.vx * deltaSeconds; sp.y += sp.vy * deltaSeconds; sp.vy *= 0.995f; // slight damping sp.pulse += deltaSeconds * 8.0f; // Fade and pulse alpha float pulse = 0.5f + 0.5f * std::sin(sp.pulse); Uint8 alpha = static_cast(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha); float half = sp.size * 0.5f; SDL_FRect fr{gridX + sp.x - half, gridY + sp.y - half, sp.size, sp.size}; SDL_RenderFillRect(renderer, &fr); ++it; } } SDL_SetRenderDrawBlendMode(renderer, oldBlend); renderNextPanel(renderer, pixelFont, blocksTex, game->next(), NEXT_PANEL_X, NEXT_PANEL_Y, NEXT_PANEL_WIDTH, NEXT_PANEL_HEIGHT, finalBlockSize); // Draw a small filled connector to visually merge NEXT panel and grid border SDL_SetRenderDrawColor(renderer, 60, 80, 160, 255); // same as grid border float connectorY = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT; // bottom of next panel (near grid top) SDL_FRect connRect{ NEXT_PANEL_X, connectorY - 1.0f, NEXT_PANEL_WIDTH, 2.0f }; SDL_RenderFillRect(renderer, &connRect); // Draw transport effect if active (renders the moving piece and trail) updateAndDrawTransport(renderer, blocksTex); // 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 = 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 = !GameRenderer::isTransportActive(); const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled(); float activePiecePixelOffsetX = 0.0f; if (allowActivePieceRender) { if (smoothScrollEnabled && !game->isPaused()) { const uint64_t pieceSeq = game->getCurrentPieceSequence(); if (!s_activePieceSmooth.initialized || s_activePieceSmooth.sequence != pieceSeq) { s_activePieceSmooth.sequence = pieceSeq; s_activePieceSmooth.visualX = static_cast(game->current().x); s_activePieceSmooth.initialized = true; } const float targetX = static_cast(game->current().x); constexpr float HORIZONTAL_SMOOTH_MS = 55.0f; const float lerpFactor = std::clamp(sparkDeltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f); s_activePieceSmooth.visualX = std::lerp(s_activePieceSmooth.visualX, targetX, lerpFactor); activePiecePixelOffsetX = (s_activePieceSmooth.visualX - targetX) * finalBlockSize; } else { s_activePieceSmooth.sequence = game->getCurrentPieceSequence(); s_activePieceSmooth.visualX = static_cast(game->current().x); s_activePieceSmooth.initialized = true; } } auto computeFallOffset = [&]() -> float { if (game->isPaused()) { return 0.0f; } double gravityMs = game->getGravityMs(); if (gravityMs <= 0.0) { return 0.0f; } double effectiveMs = game->isSoftDropping() ? std::max(5.0, gravityMs / 5.0) : gravityMs; double accumulator = std::clamp(game->getFallAccumulator(), 0.0, effectiveMs); if (effectiveMs <= 0.0) { return 0.0f; } float progress = static_cast(accumulator / effectiveMs); progress = std::clamp(progress, 0.0f, 1.0f); return progress * finalBlockSize; }; float activePiecePixelOffsetY = (!game->isPaused() && smoothScrollEnabled) ? computeFallOffset() : 0.0f; if (activePiecePixelOffsetY > 0.0f) { const auto& boardRef = game->boardRef(); const Game::Piece& piece = game->current(); float maxAllowed = finalBlockSize; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!Game::cellFilled(piece, cx, cy)) { continue; } int gx = piece.x + cx; int gy = piece.y + cy; if (gx < 0 || gx >= Game::COLS) { continue; } int testY = gy + 1; int emptyRows = 0; if (testY < 0) { emptyRows -= testY; // number of rows until we reach row 0 testY = 0; } while (testY >= 0 && testY < Game::ROWS) { if (boardRef[testY * Game::COLS + gx] != 0) { break; } ++emptyRows; ++testY; } float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f; maxAllowed = std::min(maxAllowed, cellLimit); } } activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed); } // Draw ghost piece (where current piece will land) if (allowActivePieceRender) { Game::Piece ghostPiece = game->current(); // Find landing position while (true) { Game::Piece testPiece = ghostPiece; testPiece.y++; bool collision = false; // Simple collision check for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (Game::cellFilled(testPiece, cx, cy)) { int gx = testPiece.x + cx; int gy = testPiece.y + cy; if (gy >= Game::ROWS || gx < 0 || gx >= Game::COLS || (gy >= 0 && board[gy * Game::COLS + gx] != 0)) { collision = true; break; } } } if (collision) break; } if (collision) break; ghostPiece = testPiece; } // Draw ghost piece drawPiece(renderer, blocksTex, ghostPiece, gridX, gridY, finalBlockSize, true); } // Draw the falling piece if (allowActivePieceRender) { drawPiece(renderer, blocksTex, game->current(), gridX, gridY, finalBlockSize, false, activePiecePixelOffsetX, activePiecePixelOffsetY); } // Draw line clearing effects if (lineEffect && lineEffect->isActive()) { lineEffect->render(renderer, blocksTex, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize)); } // Draw block statistics (left panel) -> STATISTICS console const auto& blockCounts = game->getBlockCounts(); int totalBlocks = 0; for (int i = 0; i < PIECE_COUNT; ++i) totalBlocks += blockCounts[i]; // Header (slightly smaller) const SDL_Color headerColor{255, 220, 0, 255}; const SDL_Color textColor{200, 220, 235, 200}; const SDL_Color mutedColor{150, 180, 200, 180}; pixelFont->draw(renderer, statsX + 12.0f, statsY + 8.0f, "STATISTICS", 0.92f, headerColor); // Tighter spacing and smaller icons/text for compact analytics console float yCursor = statsY + 36.0f; const float leftPad = 12.0f; const float rightPad = 14.0f; // Increase row gap to avoid icon overlap on smaller scales const float rowGap = 20.0f; const float barHeight = 2.0f; // Determine max percent to highlight top used piece int maxPerc = 0; for (int i = 0; i < PIECE_COUNT; ++i) { int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(blockCounts[i]) / double(totalBlocks))) : 0; if (perc > maxPerc) maxPerc = perc; } // Row order groups: first 4, then last 3 std::vector order = {0,1,2,3, 4,5,6}; for (size_t idx = 0; idx < order.size(); ++idx) { int i = order[idx]; float rowLeft = statsX + leftPad; float rowRight = statsX + statsW - rightPad; // Icon card with a small backing to match the reference layout float iconSize = finalBlockSize * 0.52f; float iconBgPad = 6.0f; float iconBgW = iconSize * 3.0f + iconBgPad * 2.0f; float iconBgH = iconSize * 3.0f + iconBgPad * 2.0f; float iconBgX = rowLeft - 6.0f; float iconBgY = yCursor - 10.0f; SDL_SetRenderDrawColor(renderer, 14, 20, 32, 210); SDL_FRect iconBg{iconBgX, iconBgY, iconBgW, iconBgH}; SDL_RenderFillRect(renderer, &iconBg); SDL_SetRenderDrawColor(renderer, 40, 70, 110, 180); SDL_RenderRect(renderer, &iconBg); // Measure right-side text first so we can vertically align icon with text int count = blockCounts[i]; char countStr[16]; snprintf(countStr, sizeof(countStr), "%dx", count); int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(count) / double(totalBlocks))) : 0; char percStr[16]; snprintf(percStr, sizeof(percStr), "%d%%", perc); int countW=0, countH=0; pixelFont->measure(countStr, 0.82f, countW, countH); int percW=0, percH=0; pixelFont->measure(percStr, 0.78f, percW, percH); float iconX = iconBgX + iconBgPad; float iconY = iconBgY + iconBgPad + 2.0f; drawSmallPiece(renderer, blocksTex, static_cast(i), iconX, iconY, iconSize); // Badge for counts/percent so text sits on a soft dark backing const float numbersGap = 14.0f; const float numbersPadX = 10.0f; const float numbersPadY = 6.0f; int maxTextH = std::max(countH, percH); float numbersW = numbersPadX * 2.0f + countW + numbersGap + percW; float numbersH = numbersPadY * 2.0f + static_cast(maxTextH); float numbersX = rowRight - numbersW; float numbersY = yCursor - (numbersH - static_cast(maxTextH)) * 0.5f; SDL_SetRenderDrawColor(renderer, 32, 44, 60, 210); SDL_FRect numbersBg{numbersX, numbersY, numbersW, numbersH}; SDL_RenderFillRect(renderer, &numbersBg); float textY = numbersY + (numbersH - static_cast(maxTextH)) * 0.5f; float countX = numbersX + numbersPadX; float percX = numbersX + numbersW - percW - numbersPadX; pixelFont->draw(renderer, countX, textY, countStr, 0.82f, textColor); pixelFont->draw(renderer, percX, textY, percStr, 0.78f, mutedColor); // Progress bar anchored to the numbers badge, matching the reference width float barX = numbersX; float barW = numbersW; float barY = numbersY + numbersH + 10.0f; SDL_SetRenderDrawColor(renderer, 24, 80, 120, 220); SDL_FRect track{barX, barY, barW, barHeight}; SDL_RenderFillRect(renderer, &track); // Fill color brightness based on usage and highlight for top piece float strength = (totalBlocks > 0) ? (float(blockCounts[i]) / float(totalBlocks)) : 0.0f; SDL_Color baseC = {60, 200, 255, 255}; SDL_Color dimC = {40, 120, 160, 255}; SDL_Color fillC = (perc == maxPerc) ? SDL_Color{100, 230, 255, 255} : SDL_Color{ static_cast(std::lerp((float)dimC.r, (float)baseC.r, strength)), static_cast(std::lerp((float)dimC.g, (float)baseC.g, strength)), static_cast(std::lerp((float)dimC.b, (float)baseC.b, strength)), 255 }; float fillW = barW * std::clamp(strength, 0.0f, 1.0f); SDL_SetRenderDrawColor(renderer, fillC.r, fillC.g, fillC.b, fillC.a); SDL_FRect fill{barX, barY, fillW, barHeight}; SDL_RenderFillRect(renderer, &fill); // Advance cursor to next row: after bar + gap (leave 20px space before next block) yCursor = barY + barHeight + rowGap; if (idx == 3) { // faint separator SDL_SetRenderDrawColor(renderer, 60, 80, 140, 90); SDL_FRect sep{statsX + 8.0f, yCursor, statsW - 16.0f, 1.5f}; SDL_RenderFillRect(renderer, &sep); yCursor += 8.0f; } } // Bottom summary stats float summaryY = statsY + statsH - 90.0f; // move summary slightly up const SDL_Color summaryValueColor{220, 235, 250, 255}; const SDL_Color labelMuted{160, 180, 200, 200}; char totalStr[32]; snprintf(totalStr, sizeof(totalStr), "%d", totalBlocks); char tetrisesStr[32]; snprintf(tetrisesStr, sizeof(tetrisesStr), "%d", game->tetrisesMade()); char maxComboStr[32]; snprintf(maxComboStr, sizeof(maxComboStr), "%d", game->maxCombo()); // Use slightly smaller labels/values to match the compact look const float labelX = statsX + 8.0f; // move labels more left const float valueRightPad = 12.0f; // pad from right edge int valW=0, valH=0; pixelFont->measure(totalStr, 0.82f, valW, valH); float totalX = statsX + statsW - valueRightPad - (float)valW; pixelFont->draw(renderer, labelX, summaryY + 0.0f, "TOTAL PIECES", 0.72f, labelMuted); pixelFont->draw(renderer, totalX, summaryY + 0.0f, totalStr, 0.82f, summaryValueColor); pixelFont->measure(tetrisesStr, 0.82f, valW, valH); float tetrisesX = statsX + statsW - valueRightPad - (float)valW; pixelFont->draw(renderer, labelX, summaryY + 22.0f, "TETRISES MADE", 0.72f, labelMuted); pixelFont->draw(renderer, tetrisesX, summaryY + 22.0f, tetrisesStr, 0.82f, summaryValueColor); pixelFont->measure(maxComboStr, 0.82f, valW, valH); float comboX = statsX + statsW - valueRightPad - (float)valW; pixelFont->draw(renderer, labelX, summaryY + 44.0f, "MAX COMBO", 0.72f, labelMuted); pixelFont->draw(renderer, comboX, summaryY + 44.0f, maxComboStr, 0.82f, summaryValueColor); // Draw score panel (right side) const float contentTopOffset = 0.0f; const float contentBottomOffset = 290.0f; const float contentPad = 36.0f; float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad; float baseY = gridY + (GRID_H - scoreContentH) * 0.5f; const float statsPanelGap = 12.0f; const float statsPanelLeft = gridX + GRID_W + statsPanelGap; const float statsPanelPadLeft = 40.0f; const float statsPanelPadRight = 34.0f; const float statsPanelPadY = 28.0f; const float statsTextX = statsPanelLeft + statsPanelPadLeft; const SDL_Color labelColor{255, 220, 0, 255}; const SDL_Color valueColor{255, 255, 255, 255}; const SDL_Color nextColor{80, 255, 120, 255}; char scoreStr[32]; snprintf(scoreStr, sizeof(scoreStr), "%d", game->score()); char linesStr[16]; snprintf(linesStr, sizeof(linesStr), "%03d", game->lines()); char levelStr[16]; snprintf(levelStr, sizeof(levelStr), "%02d", game->level()); // Next level progress int startLv = game->startLevelBase(); int firstThreshold = (startLv + 1) * 10; int linesDone = game->lines(); int nextThreshold = 0; if (linesDone < firstThreshold) { nextThreshold = firstThreshold; } else { int blocksPast = linesDone - firstThreshold; nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10; } int linesForNext = std::max(0, nextThreshold - linesDone); char nextStr[32]; snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext); // Time display int totalSecs = static_cast(game->elapsed()); int mins = totalSecs / 60; int secs = totalSecs % 60; char timeStr[16]; snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs); const bool debugEnabled = Settings::instance().isDebugEnabled(); char gravityStr[32] = ""; char dropStr[32] = ""; char gravityHud[64] = ""; SDL_Color dropColor{100, 255, 100, 255}; if (debugEnabled) { double gravityMs = game->getGravityMs(); double fallAcc = game->getFallAccumulator(); bool isSoftDrop = game->isSoftDropping(); double effectiveGravityMs = isSoftDrop ? (gravityMs / 2.0) : gravityMs; double timeUntilDrop = std::max(0.0, effectiveGravityMs - fallAcc); snprintf(gravityStr, sizeof(gravityStr), "%.0f ms%s", gravityMs, isSoftDrop ? " (SD)" : ""); snprintf(dropStr, sizeof(dropStr), "Drop: %.0f", timeUntilDrop); dropColor = isSoftDrop ? SDL_Color{255, 200, 100, 255} : SDL_Color{100, 255, 100, 255}; double gfps = gravityMs > 0.0 ? (1000.0 / gravityMs) : 0.0; snprintf(gravityHud, sizeof(gravityHud), "GRAV: %.0f ms (%.2f fps)", gravityMs, gfps); } struct StatLine { const char* text; float offsetY; float scale; SDL_Color color; }; std::vector statLines; statLines.reserve(debugEnabled ? 13 : 10); statLines.push_back({"SCORE", 0.0f, 1.0f, labelColor}); statLines.push_back({scoreStr, 25.0f, 0.9f, valueColor}); statLines.push_back({"LINES", 70.0f, 1.0f, labelColor}); statLines.push_back({linesStr, 95.0f, 0.9f, valueColor}); statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor}); statLines.push_back({levelStr, 165.0f, 0.9f, valueColor}); statLines.push_back({"NEXT LVL", 200.0f, 1.0f, labelColor}); statLines.push_back({nextStr, 225.0f, 0.9f, nextColor}); statLines.push_back({"TIME", 265.0f, 1.0f, labelColor}); statLines.push_back({timeStr, 290.0f, 0.9f, valueColor}); if (debugEnabled) { SDL_Color debugLabelColor{150, 150, 150, 255}; SDL_Color debugValueColor{180, 180, 180, 255}; statLines.push_back({"GRAVITY", 330.0f, 0.8f, debugLabelColor}); statLines.push_back({gravityStr, 350.0f, 0.7f, debugValueColor}); statLines.push_back({dropStr, 370.0f, 0.7f, dropColor}); } if (!statLines.empty()) { float statsContentTop = std::numeric_limits::max(); float statsContentBottom = std::numeric_limits::lowest(); float statsContentMaxWidth = 0.0f; for (const auto& line : statLines) { int textW = 0; int textH = 0; pixelFont->measure(line.text, line.scale, textW, textH); float y = baseY + line.offsetY; statsContentTop = std::min(statsContentTop, y); statsContentBottom = std::max(statsContentBottom, y + textH); statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast(textW)); } float statsPanelWidth = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight; float statsPanelHeight = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f; float statsPanelTop = statsContentTop - statsPanelPadY; SDL_FRect statsBg{statsPanelLeft, statsPanelTop, statsPanelWidth, statsPanelHeight}; if (scorePanelTex) { SDL_RenderTexture(renderer, scorePanelTex, nullptr, &statsBg); } else { SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); SDL_RenderFillRect(renderer, &statsBg); } } for (const auto& line : statLines) { pixelFont->draw(renderer, statsTextX, baseY + line.offsetY, line.text, line.scale, line.color); } if (debugEnabled) { pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255}); } // Hold piece (if implemented) if (game->held().type < PIECE_COUNT) { pixelFont->draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255}); drawSmallPiece(renderer, blocksTex, static_cast(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f); } // Pause overlay logic moved to renderPauseOverlay // Exit popup logic moved to renderExitPopup } void GameRenderer::renderExitPopup( SDL_Renderer* renderer, FontAtlas* pixelFont, float winW, float winH, float logicalScale, int selectedButton ) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_Rect oldViewport; SDL_GetRenderViewport(renderer, &oldViewport); float oldScaleX = 1.0f; float oldScaleY = 1.0f; SDL_GetRenderScale(renderer, &oldScaleX, &oldScaleY); SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderScale(renderer, 1.0f, 1.0f); SDL_SetRenderDrawColor(renderer, 2, 4, 12, 210); SDL_FRect fullWin{0.0f, 0.0f, winW, winH}; SDL_RenderFillRect(renderer, &fullWin); const float scale = std::max(0.8f, logicalScale); const float panelW = 740.0f * scale; const float panelH = 380.0f * scale; SDL_FRect panel{ (winW - panelW) * 0.5f, (winH - panelH) * 0.5f, panelW, panelH }; SDL_FRect shadow{ panel.x + 14.0f * scale, panel.y + 16.0f * scale, panel.w + 4.0f * scale, panel.h + 4.0f * scale }; SDL_SetRenderDrawColor(renderer, 0, 0, 0, 140); SDL_RenderFillRect(renderer, &shadow); const std::array panelLayers{ SDL_Color{7, 10, 22, 255}, SDL_Color{12, 22, 40, 255}, SDL_Color{18, 32, 56, 255} }; for (size_t i = 0; i < panelLayers.size(); ++i) { float inset = static_cast(i) * 6.0f * scale; SDL_FRect layer{ panel.x + inset, panel.y + inset, panel.w - inset * 2.0f, panel.h - inset * 2.0f }; SDL_Color c = panelLayers[i]; SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); SDL_RenderFillRect(renderer, &layer); } SDL_SetRenderDrawColor(renderer, 60, 90, 150, 255); SDL_RenderRect(renderer, &panel); SDL_FRect insetFrame{ panel.x + 10.0f * scale, panel.y + 10.0f * scale, panel.w - 20.0f * scale, panel.h - 20.0f * scale }; SDL_SetRenderDrawColor(renderer, 24, 45, 84, 255); SDL_RenderRect(renderer, &insetFrame); const float contentPad = 44.0f * scale; float textX = panel.x + contentPad; float contentWidth = panel.w - contentPad * 2.0f; float cursorY = panel.y + contentPad * 0.6f; const char* title = "EXIT GAME?"; const float titleScale = 2.0f * scale; int titleW = 0, titleH = 0; pixelFont->measure(title, titleScale, titleW, titleH); pixelFont->draw(renderer, textX, cursorY, title, titleScale, SDL_Color{255, 224, 130, 255}); cursorY += titleH + 18.0f * scale; SDL_SetRenderDrawColor(renderer, 32, 64, 110, 210); SDL_FRect divider{textX, cursorY, contentWidth, 2.0f * scale}; SDL_RenderFillRect(renderer, ÷r); cursorY += 26.0f * scale; const std::array lines{ "Are you sure you want to quit?", "Current progress will be lost." }; const float bodyScale = 1.05f * scale; for (const char* line : lines) { int lineW = 0, lineH = 0; pixelFont->measure(line, bodyScale, lineW, lineH); pixelFont->draw(renderer, textX, cursorY, line, bodyScale, SDL_Color{210, 226, 245, 255}); cursorY += lineH + 10.0f * scale; } const char* tip = "Enter confirms • Esc returns"; const float tipScale = 0.9f * scale; int tipW = 0, tipH = 0; pixelFont->measure(tip, tipScale, tipW, tipH); const float buttonGap = 32.0f * scale; const float buttonH = 78.0f * scale; const float buttonW = (contentWidth - buttonGap) * 0.5f; float buttonY = panel.y + panel.h - contentPad - buttonH; float tipX = panel.x + (panel.w - tipW) * 0.5f; float tipY = buttonY - tipH - 14.0f * scale; pixelFont->draw(renderer, tipX, tipY, tip, tipScale, SDL_Color{150, 170, 205, 255}); auto drawButton = [&](int idx, float btnX, SDL_Color baseColor, const char* label) { bool selected = (selectedButton == idx); SDL_FRect btn{btnX, buttonY, buttonW, buttonH}; SDL_Color body = baseColor; if (selected) { body.r = Uint8(std::min(255, body.r + 35)); body.g = Uint8(std::min(255, body.g + 35)); body.b = Uint8(std::min(255, body.b + 35)); } SDL_Color border = selected ? SDL_Color{255, 225, 150, 255} : SDL_Color{90, 120, 170, 255}; SDL_Color topEdge = SDL_Color{Uint8(std::min(255, body.r + 20)), Uint8(std::min(255, body.g + 20)), Uint8(std::min(255, body.b + 20)), 255}; SDL_SetRenderDrawColor(renderer, 0, 0, 0, 110); SDL_FRect btnShadow{btn.x + 6.0f * scale, btn.y + 8.0f * scale, btn.w, btn.h}; SDL_RenderFillRect(renderer, &btnShadow); SDL_SetRenderDrawColor(renderer, body.r, body.g, body.b, body.a); SDL_RenderFillRect(renderer, &btn); SDL_FRect topStrip{btn.x, btn.y, btn.w, 6.0f * scale}; SDL_SetRenderDrawColor(renderer, topEdge.r, topEdge.g, topEdge.b, topEdge.a); SDL_RenderFillRect(renderer, &topStrip); SDL_SetRenderDrawColor(renderer, border.r, border.g, border.b, border.a); SDL_RenderRect(renderer, &btn); if (selected) { SDL_SetRenderDrawColor(renderer, 255, 230, 160, 90); SDL_FRect glow{ btn.x - 6.0f * scale, btn.y - 6.0f * scale, btn.w + 12.0f * scale, btn.h + 12.0f * scale }; SDL_RenderRect(renderer, &glow); } const float labelScale = 1.35f * scale; int labelW = 0, labelH = 0; pixelFont->measure(label, labelScale, labelW, labelH); float textX = btn.x + (btn.w - labelW) * 0.5f; float textY = btn.y + (btn.h - labelH) * 0.5f; SDL_Color textColor = selected ? SDL_Color{255, 255, 255, 255} : SDL_Color{235, 238, 250, 255}; pixelFont->draw(renderer, textX, textY, label, labelScale, textColor); }; float yesX = textX; float noX = yesX + buttonW + buttonGap; drawButton(0, yesX, SDL_Color{190, 70, 70, 255}, "YES"); drawButton(1, noX, SDL_Color{70, 115, 190, 255}, "NO"); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); SDL_SetRenderViewport(renderer, &oldViewport); SDL_SetRenderScale(renderer, oldScaleX, oldScaleY); } void GameRenderer::renderPauseOverlay( SDL_Renderer* renderer, FontAtlas* pixelFont, float winW, float winH, float logicalScale ) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); // Switch to window coordinates for the full-screen overlay and text SDL_Rect oldViewport; SDL_GetRenderViewport(renderer, &oldViewport); float oldScaleX, oldScaleY; SDL_GetRenderScale(renderer, &oldScaleX, &oldScaleY); SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderScale(renderer, 1.0f, 1.0f); // Draw full screen overlay (darken) SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); SDL_FRect pauseOverlay{0, 0, winW, winH}; SDL_RenderFillRect(renderer, &pauseOverlay); // Draw centered text const char* pausedText = "PAUSED"; float pausedScale = 2.0f * logicalScale; int pW = 0, pH = 0; pixelFont->measure(pausedText, pausedScale, pW, pH); pixelFont->draw(renderer, (winW - pW) * 0.5f, (winH - pH) * 0.5f - (20 * logicalScale), pausedText, pausedScale, {255, 255, 255, 255}); const char* resumeText = "Press P to resume"; float resumeScale = 0.8f * logicalScale; int rW = 0, rH = 0; pixelFont->measure(resumeText, resumeScale, rW, rH); pixelFont->draw(renderer, (winW - rW) * 0.5f, (winH - pH) * 0.5f + (40 * logicalScale), resumeText, resumeScale, {200, 200, 220, 255}); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); // Restore previous render state SDL_SetRenderViewport(renderer, &oldViewport); SDL_SetRenderScale(renderer, oldScaleX, oldScaleY); }