#include "GameRenderer.h" #include "../../gameplay/core/Game.h" #include "../../gameplay/coop/CoopGame.h" #include "../../app/Fireworks.h" #include "../ui/Font.h" #include "../../gameplay/effects/LineEffect.h" #include #include #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 AsteroidBurst { float x; float y; float lifeMs; float maxLifeMs; float baseRadius; SDL_Color color; float spin; }; std::vector s_asteroidBursts; struct AsteroidShard { float x; float y; float vx; float vy; float lifeMs; float maxLifeMs; float size; SDL_Color color; }; std::vector s_asteroidShards; } 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; // Raise preview slightly to remove a thin line artifact under the panel startY = std::round(startY) - 5.0f; // 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) - 5.0f; // 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); } static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float x, float y, float size, const AsteroidCell& cell) { auto outlineGravity = [&](float inset, SDL_Color color) { SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); SDL_FRect glow{ x + inset, y + inset, size - inset * 2.0f, size - inset * 2.0f }; SDL_RenderRect(renderer, &glow); }; if (asteroidTex) { const float SPRITE_SIZE = 90.0f; int col = 0; switch (cell.type) { case AsteroidType::Normal: col = 0; break; case AsteroidType::Armored: col = 1; break; case AsteroidType::Falling: col = 2; break; case AsteroidType::Core: col = 3; break; } int row = std::clamp(cell.visualState, 0, 2); SDL_FRect src{ col * SPRITE_SIZE, row * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE }; SDL_FRect dst{ x, y, size, size }; SDL_RenderTexture(renderer, asteroidTex, &src, &dst); if (cell.gravityEnabled) { outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); } return; } // Fallback: draw a colored quad (previous implementation) SDL_Color base{}; switch (cell.type) { case AsteroidType::Normal: base = SDL_Color{172, 138, 104, 255}; break; case AsteroidType::Armored: base = SDL_Color{130, 150, 176, 255}; break; case AsteroidType::Falling: base = SDL_Color{210, 120, 82, 255}; break; case AsteroidType::Core: base = SDL_Color{198, 78, 200, 255}; break; } float hpScale = std::clamp(static_cast(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f); SDL_Color fill{ static_cast(base.r * hpScale + 40 * (1.0f - hpScale)), static_cast(base.g * hpScale + 40 * (1.0f - hpScale)), static_cast(base.b * hpScale + 40 * (1.0f - hpScale)), 255 }; SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); SDL_FRect body{x, y, size - 1.0f, size - 1.0f}; SDL_RenderFillRect(renderer, &body); SDL_Color outline = base; outline.a = 220; SDL_FRect border{x + 1.0f, y + 1.0f, size - 2.0f, size - 2.0f}; SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a); SDL_RenderRect(renderer, &border); if (cell.gravityEnabled) { outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); } } void GameRenderer::drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) { if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) { // Fallback to colored rectangle if texture isn't available 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, SDL_Texture* nextPanelTex, 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}; // If an external NEXT panel texture is provided, draw it scaled into // the panel rectangle and skip the custom background/frame drawing. if (nextPanelTex) { SDL_FRect dst{panelX, panelY, panelW, panelH}; SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst); // Draw the panel label over the texture — user requested visible label const float labelPad = tileSize * 0.25f; pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor); } else { 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 and nudge upward slightly startY = std::round(startY) - 5.0f; // 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* asteroidsTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, SDL_Texture* holdPanelTex, bool countdownActive, float logicalW, float logicalH, float logicalScale, float winW, float winH, bool challengeClearFxActive, const std::vector* challengeClearFxOrder, double challengeClearFxElapsedMs, double challengeClearFxDurationMs, const std::string* challengeStoryText, float challengeStoryAlpha ) { 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)); // Trigger fireworks visually for a 4-line clear (TETRIS) if (completedLines.size() == 4) { // spawn near center of grid AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f); } } // 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; scale it to fit the panel while // preserving aspect ratio (no crop) so very tall source images don't overflow. float texWf = 0.0f, texHf = 0.0f; if (SDL_GetTextureSize(statisticsPanelTex, &texWf, &texHf)) { if (texWf > 0.0f && texHf > 0.0f) { const float destW = blocksPanelBg.w; const float destH = blocksPanelBg.h; const float scale = std::min(destW / texWf, destH / texHf); const float scaledW = texWf * scale; const float scaledH = texHf * scale; // Center the scaled texture inside the panel SDL_FRect dstF{ blocksPanelBg.x + (destW - scaledW) * 0.5f, blocksPanelBg.y + (destH - scaledH) * 0.5f, scaledW, scaledH }; SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &dstF); } } else { // Fallback: render entire texture stretched to panel 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); // Add a small, smooth sub-pixel jitter to the starfield origin so the // brightest star doesn't permanently sit exactly at the visual center. { const float jitterAmp = 1.6f; // max pixels of jitter const uint32_t now = SDL_GetTicks(); const float tms = static_cast(now) * 0.001f; const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f; const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f); s_inGridStarfield.draw(renderer, gridX + jitterX, gridY + jitterY, 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, nextPanelTex, 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 // If an external NEXT panel texture is used, skip the connector to avoid // drawing a visible seam under the image/artwork. if (!nextPanelTex) { 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); } // Spawn glamour bursts for freshly destroyed asteroids if (game) { const auto& bursts = game->getRecentAsteroidExplosions(); if (!bursts.empty()) { std::uniform_real_distribution lifeDist(280.0f, 420.0f); std::uniform_real_distribution radiusDist(finalBlockSize * 0.35f, finalBlockSize * 0.7f); std::uniform_real_distribution spinDist(-4.0f, 4.0f); std::uniform_real_distribution shardLife(240.0f, 520.0f); std::uniform_real_distribution shardVX(-0.16f, 0.16f); std::uniform_real_distribution shardVY(-0.22f, -0.06f); std::uniform_real_distribution shardSize(finalBlockSize * 0.06f, finalBlockSize * 0.12f); for (const auto& p : bursts) { if (p.x < 0 || p.x >= Game::COLS || p.y < 0 || p.y >= Game::ROWS) { continue; } float fx = gridX + (static_cast(p.x) + 0.5f) * finalBlockSize; float fy = gridY + (static_cast(p.y) + 0.5f) * finalBlockSize + rowDropOffsets[p.y]; SDL_Color palette[3] = { SDL_Color{255, 230, 120, 255}, SDL_Color{140, 220, 255, 255}, SDL_Color{255, 160, 235, 255} }; SDL_Color c = palette[s_impactRng() % 3]; AsteroidBurst burst{ fx, fy, lifeDist(s_impactRng), 0.0f, radiusDist(s_impactRng), c, spinDist(s_impactRng) }; burst.maxLifeMs = burst.lifeMs; s_asteroidBursts.push_back(burst); // Spawn shards for extra sparkle int shardCount = 10 + (s_impactRng() % 8); for (int i = 0; i < shardCount; ++i) { AsteroidShard shard{ fx, fy, shardVX(s_impactRng), shardVY(s_impactRng), shardLife(s_impactRng), 0.0f, shardSize(s_impactRng), c }; shard.maxLifeMs = shard.lifeMs; s_asteroidShards.push_back(shard); } } game->clearRecentAsteroidExplosions(); } } // Draw the game board const auto &board = game->boardRef(); const auto &asteroidCells = game->asteroidCells(); const bool challengeMode = game->getMode() == GameMode::Challenge; 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); } } } 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) { 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)); } 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]; float spawnScale = 1.0f; float spawnAlpha = 1.0f; if (countdownActive) { // Staggered pop-in while counting: start oversized, fade to 1.0 with ease const float t = static_cast(SDL_GetTicks() & 2047) * 0.0015f; // ~0..3s loop float phase = std::fmod(t + (float(cellIdx % 11) * 0.12f), 1.6f); float pulse = std::clamp(phase / 1.2f, 0.0f, 1.0f); float eased = smoothstep(pulse); spawnScale = 1.35f - 0.35f * eased; // big -> normal spawnAlpha = 0.25f + 0.75f * eased; // fade in } if (asteroidsTex && spawnAlpha < 1.0f) { SDL_SetTextureAlphaMod(asteroidsTex, static_cast(std::clamp(spawnAlpha, 0.0f, 1.0f) * 255.0f)); } 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 || clearAlpha < 1.0f)) { SDL_SetTextureAlphaMod(asteroidsTex, 255); } } else { 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); } } } } } // Update & draw asteroid glamour shards and bursts if (!s_asteroidShards.empty() || !s_asteroidBursts.empty()) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); // Shards auto shardIt = s_asteroidShards.begin(); while (shardIt != s_asteroidShards.end()) { AsteroidShard& s = *shardIt; s.lifeMs -= sparkDeltaMs; if (s.lifeMs <= 0.0f) { shardIt = s_asteroidShards.erase(shardIt); continue; } s.vy += 0.0007f * sparkDeltaMs; s.x += s.vx * sparkDeltaMs; s.y += s.vy * sparkDeltaMs; float lifeRatio = std::clamp(static_cast(s.lifeMs / s.maxLifeMs), 0.0f, 1.0f); Uint8 alpha = static_cast(lifeRatio * 200.0f); SDL_SetRenderDrawColor(renderer, s.color.r, s.color.g, s.color.b, alpha); float size = s.size * (0.7f + (1.0f - lifeRatio) * 0.8f); SDL_FRect shardRect{ s.x - size * 0.5f, s.y - size * 0.5f, size, size * 1.4f }; SDL_RenderFillRect(renderer, &shardRect); ++shardIt; } // Bursts auto it = s_asteroidBursts.begin(); while (it != s_asteroidBursts.end()) { AsteroidBurst& b = *it; b.lifeMs -= sparkDeltaMs; if (b.lifeMs <= 0.0f) { it = s_asteroidBursts.erase(it); continue; } float t = 1.0f - static_cast(b.lifeMs / b.maxLifeMs); float alpha = std::clamp(1.0f - t, 0.0f, 1.0f); float radius = b.baseRadius * (1.0f + t * 1.6f); float thickness = std::max(2.0f, radius * 0.25f); float jitter = std::sin(t * 12.0f + b.spin) * 2.0f; SDL_Color c = b.color; Uint8 a = static_cast(alpha * 220.0f); SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a); SDL_FRect outer{ b.x - radius + jitter, b.y - radius + jitter, radius * 2.0f, radius * 2.0f }; SDL_RenderRect(renderer, &outer); SDL_FRect inner{ b.x - (radius - thickness), b.y - (radius - thickness), (radius - thickness) * 2.0f, (radius - thickness) * 2.0f }; SDL_SetRenderDrawColor(renderer, 255, 255, 255, static_cast(a * 0.9f)); SDL_RenderRect(renderer, &inner); ++it; } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); } 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() && !challengeClearActive; 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); } // Debug: log single-player smoothing/fall values when enabled if (Settings::instance().isDebugEnabled()) { float sp_targetX = static_cast(game->current().x); double sp_gravityMs = game->getGravityMs(); double sp_fallAcc = game->getFallAccumulator(); int sp_soft = game->isSoftDropping() ? 1 : 0; /* SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d", (unsigned long long)s_activePieceSmooth.sequence, s_activePieceSmooth.visualX, sp_targetX, activePiecePixelOffsetX, activePiecePixelOffsetY, sp_gravityMs, sp_fallAcc, sp_soft ); */ } // 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}; // Small vertical shift to push text further into the statistics panel // Increased from 20 to 80 to move text down by ~60px more (within 50-100px range) const float statsTextShift = 80.0f; pixelFont->draw(renderer, statsX + 12.0f, statsY + 8.0f + statsTextShift, "STATISTICS", 0.70f, headerColor); // Tighter spacing and smaller icons/text for compact analytics console float yCursor = statsY + 34.0f + statsTextShift; const float leftPad = 2.0f; const float rightPad = 14.0f; // Horizontal shift to nudge the count/percent group closer to the right edge const float statsNumbersShift = 20.0f; // Horizontal shift to move the progress bar slightly left const float statsBarShift = -10.0f; // Increase row gap to avoid icon overlap on smaller scales (bumped up) const float rowGap = 28.0f; const float barHeight = 2.0f; // Text scales for per-row numbers (slightly larger than before) const float countScale = 0.60f; const float percScale = 0.60f; // 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}; // Precompute max widths for counts/perc so right-alignment is stable int maxCountW = 0, maxCountH = 0; int maxPercW = 0, maxPercH = 0; for (int oi : order) { char tmpCount[16]; snprintf(tmpCount, sizeof(tmpCount), "%dx", blockCounts[oi]); char tmpPerc[16]; int tmpPercVal = (totalBlocks > 0) ? int(std::round(100.0 * double(blockCounts[oi]) / double(totalBlocks))) : 0; snprintf(tmpPerc, sizeof(tmpPerc), "%d%%", tmpPercVal); pixelFont->measure(tmpCount, countScale, maxCountW, maxCountH); int pw=0, ph=0; pixelFont->measure(tmpPerc, percScale, pw, ph); maxPercW = std::max(maxPercW, pw); maxPercH = std::max(maxPercH, ph); } for (size_t idx = 0; idx < order.size(); ++idx) { int i = order[idx]; float rowLeft = statsX + leftPad; float rowRight = statsX + statsW - rightPad; // Icon aligned left, slightly higher to match reference spacing float iconSize = finalBlockSize * 0.52f; float iconBgPad = 6.0f; float iconBgX = rowLeft - 18.0f; float iconBgY = yCursor - 10.0f; // 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 = maxCountW, countH = maxCountH; int percW = maxPercW, percH = maxPercH; float iconX = iconBgX + iconBgPad; float iconY = yCursor - 6.0f + ((float)countH - iconSize) * 0.5f; drawSmallPiece(renderer, blocksTex, static_cast(i), iconX, iconY, iconSize); // Badge for counts/percent with no background, aligned to the right 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 + statsNumbersShift; float numbersY = yCursor - (numbersH - static_cast(maxTextH)) * 0.5f; float textY = numbersY + (numbersH - static_cast(maxTextH)) * 0.5f; // Right-align the numbers block: perc anchored to the right, count right-aligned to the perc with gap float percX = numbersX + numbersW - percW - numbersPadX; float countX = percX - numbersGap - countW; pixelFont->draw(renderer, countX, textY, countStr, countScale, textColor); pixelFont->draw(renderer, percX, textY, percStr, percScale, mutedColor); // Progress bar anchored to the numbers width float barX = numbersX + statsBarShift; float barW = numbersW; float barY = numbersY + numbersH + 8.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 more space between blocks) yCursor = barY + barHeight + rowGap + 6.0f; } // Bottom summary stats: prefer to place summary right after last row // but clamp it so it never goes below the reserved bottom area. float preferredSummaryY = yCursor + 24.0f; // space below last row float maxSummaryY = statsY + statsH - 90.0f; // original lower bound float summaryY = std::min(preferredSummaryY, maxSummaryY); 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->comboCount()); // 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.55f, valW, valH); float totalX = statsX + statsW - valueRightPad - (float)valW; pixelFont->draw(renderer, labelX, summaryY + 0.0f, "TOTAL PIECES", 0.46f, labelMuted); pixelFont->draw(renderer, totalX, summaryY + 0.0f, totalStr, 0.55f, summaryValueColor); pixelFont->measure(tetrisesStr, 0.55f, valW, valH); float tetrisesX = statsX + statsW - valueRightPad - (float)valW; pixelFont->draw(renderer, labelX, summaryY + 22.0f, "TETRISES MADE", 0.46f, labelMuted); pixelFont->draw(renderer, tetrisesX, summaryY + 22.0f, tetrisesStr, 0.55f, summaryValueColor); pixelFont->measure(maxComboStr, 0.55f, valW, valH); float comboX = statsX + statsW - valueRightPad - (float)valW; pixelFont->draw(renderer, labelX, summaryY + 44.0f, "COMBOS", 0.46f, labelMuted); pixelFont->draw(renderer, comboX, summaryY + 44.0f, maxComboStr, 0.55f, 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()); char challengeLevelStr[16]; snprintf(challengeLevelStr, sizeof(challengeLevelStr), "%02d/100", game->challengeLevel()); char asteroidStr[32]; snprintf(asteroidStr, sizeof(asteroidStr), "%d LEFT", game->asteroidsRemaining()); // Next level progress (endless only) 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}); if (game->getMode() == GameMode::Challenge) { statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor}); statLines.push_back({challengeLevelStr, 165.0f, 0.9f, valueColor}); statLines.push_back({"ASTEROIDS", 200.0f, 1.0f, labelColor}); statLines.push_back({asteroidStr, 225.0f, 0.9f, nextColor}); statLines.push_back({"TIME", 265.0f, 1.0f, labelColor}); statLines.push_back({timeStr, 290.0f, 0.9f, valueColor}); } else { 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}); } bool scorePanelMetricsValid = false; float scorePanelTop = 0.0f; float scorePanelLeftX = 0.0f; float scorePanelWidth = 0.0f; 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); } scorePanelMetricsValid = true; scorePanelTop = statsPanelTop; scorePanelLeftX = statsPanelLeft; scorePanelWidth = statsPanelWidth; } for (const auto& line : statLines) { pixelFont->draw(renderer, statsTextX, baseY + line.offsetY, line.text, line.scale, line.color); } // Challenge story / briefing line near level indicator if (challengeStoryText && !challengeStoryText->empty() && challengeStoryAlpha > 0.0f && game->getMode() == GameMode::Challenge) { float alpha = std::clamp(challengeStoryAlpha, 0.0f, 1.0f); SDL_Color storyColor{160, 220, 255, static_cast(std::lround(210.0f * alpha))}; SDL_Color shadowColor{0, 0, 0, static_cast(std::lround(120.0f * alpha))}; auto drawWrapped = [&](const std::string& text, float x, float y, float maxW, float scale, SDL_Color color) { std::istringstream iss(text); std::string word; std::string line; float cursorY = y; int lastH = 0; while (iss >> word) { std::string candidate = line.empty() ? word : (line + " " + word); int w = 0, h = 0; pixelFont->measure(candidate, scale, w, h); if (w > maxW && !line.empty()) { pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor); pixelFont->draw(renderer, x, cursorY, line, scale, color); cursorY += h + 4.0f; line = word; lastH = h; } else { line = candidate; lastH = h; } } if (!line.empty()) { pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor); pixelFont->draw(renderer, x, cursorY, line, scale, color); cursorY += lastH + 4.0f; } }; float storyX = statsTextX; float storyY = baseY + 112.0f; float maxW = 230.0f; if (scorePanelMetricsValid && scorePanelWidth > 40.0f) { storyX = scorePanelLeftX + 14.0f; maxW = std::max(160.0f, scorePanelWidth - 28.0f); } drawWrapped(*challengeStoryText, storyX, storyY, maxW, 0.7f, storyColor); } if (debugEnabled) { pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255}); } // Hold panel background & label (always visible). Small preview renders only if a piece is held. { float holdLabelX = statsTextX; float holdY = statsY + statsH - 80.0f; float holdBlockH = (finalBlockSize * 0.6f) * 6.0f; const float holdGap = 18.0f; float panelW = 120.0f; float panelH = holdBlockH + 12.0f; float panelX = holdLabelX + 40.0f; float panelY = holdY - 6.0f; if (scorePanelMetricsValid) { // align panel to score panel width and position it above it panelW = scorePanelWidth; panelX = scorePanelLeftX; panelY = scorePanelTop - panelH - holdGap; // choose label X (left edge + padding) holdLabelX = panelX + 10.0f; // label Y inside panel holdY = panelY + 8.0f; } if (holdPanelTex) { SDL_FRect panelDst{panelX, panelY, panelW, panelH}; SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND); SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR); SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst); } else { // fallback: draw a dark panel rect so UI is visible even without texture SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); SDL_FRect panelDst{panelX, panelY, panelW, panelH}; SDL_RenderFillRect(renderer, &panelDst); } // Display "HOLD" label on right side pixelFont->draw(renderer, holdLabelX + 56.0f, holdY + 4.0f, "HOLD", 1.0f, {255, 220, 0, 255}); if (game->held().type < PIECE_COUNT) { // Draw small held preview inside the panel (centered) float previewX = panelX + (panelW - (finalBlockSize * 0.6f * 4.0f)) * 0.5f; float previewY = panelY + (panelH - holdBlockH) * 2.5f; drawSmallPiece(renderer, blocksTex, static_cast(game->held().type), previewX, previewY, finalBlockSize * 0.6f); } } // Pause overlay logic moved to renderPauseOverlay // Exit popup logic moved to renderExitPopup } void GameRenderer::renderCoopPlayingState( SDL_Renderer* renderer, CoopGame* game, FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, SDL_Texture* holdPanelTex, bool paused, float logicalW, float logicalH, float logicalScale, float winW, float winH ) { if (!renderer || !game || !pixelFont) return; static Uint32 s_lastCoopTick = SDL_GetTicks(); Uint32 nowTicks = SDL_GetTicks(); float deltaMs = static_cast(nowTicks - s_lastCoopTick); s_lastCoopTick = nowTicks; if (deltaMs < 0.0f || deltaMs > 100.0f) { deltaMs = 16.0f; } 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{}; static SmoothState s_rightSmooth{}; struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; int spawnY{0}; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; }; static SpawnFadeState s_leftSpawnFade{}; static SpawnFadeState s_rightSpawnFade{}; // Layout constants (reuse single-player feel but sized for 20 cols) 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_PANEL_HEIGHT = 120.0f; const float BOTTOM_MARGIN = 60.0f; // Content offset (centered logical viewport inside 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; 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); }; 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 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 GRID_H = CoopGame::ROWS * finalBlockSize; const float totalContentHeight = NEXT_PANEL_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_PANEL_HEIGHT + contentOffsetY; const float rightPanelX = gridX + GRID_W + PANEL_SPACING; const float statsY = gridY; const float statsW = PANEL_WIDTH; const float statsH = GRID_H; // (Score panels are drawn per-player below using scorePanelTex and classic sizing.) // 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); if (completedLines.size() == 4) { AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f); } } // Precompute row drop offsets (line collapse effect) std::array rowDropOffsets{}; for (int y = 0; y < CoopGame::ROWS; ++y) { rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f); } // Grid backdrop and border drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 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); // Grid lines SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); for (int x = 1; x < CoopGame::COLS; ++x) { float lineX = gridX + 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); } // In-grid 3D starfield + ambient sparkles (match classic feel, per-half) { static Uint32 s_lastCoopSparkTick = SDL_GetTicks(); static std::mt19937 s_coopSparkRng{ std::random_device{}() }; static std::vector s_leftSparkles; static std::vector s_rightSparkles; static std::vector s_leftImpactSparks; static std::vector s_rightImpactSparks; static float s_leftSparkleSpawnAcc = 0.0f; static float s_rightSparkleSpawnAcc = 0.0f; float halfW = GRID_W * 0.5f; const float leftGridX = gridX; const float rightGridX = gridX + halfW; Uint32 sparkNow = nowTicks; float sparkDeltaMs = static_cast(sparkNow - s_lastCoopSparkTick); s_lastCoopSparkTick = sparkNow; if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) { sparkDeltaMs = 16.0f; } if (!s_starfieldInitialized) { s_inGridStarfield.init(static_cast(halfW), static_cast(GRID_H), 180); s_starfieldInitialized = true; } else { s_inGridStarfield.resize(static_cast(halfW), static_cast(GRID_H)); } const float deltaSeconds = std::clamp(sparkDeltaMs / 1000.0f, 0.0f, 0.033f); s_inGridStarfield.update(deltaSeconds); struct MagnetInfo { bool active{false}; float x{0.0f}; float y{0.0f}; }; auto computeMagnet = [&](CoopGame::PlayerSide side) -> MagnetInfo { MagnetInfo info{}; const CoopGame::Piece& activePiece = game->current(side); const int pieceType = static_cast(activePiece.type); if (pieceType < 0 || pieceType >= PIECE_COUNT) { return info; } float sumLocalX = 0.0f; float sumLocalY = 0.0f; int filledCells = 0; const int localXOffsetCols = (side == CoopGame::PlayerSide::Right) ? 10 : 0; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(activePiece, cx, cy)) continue; sumLocalX += ((activePiece.x - localXOffsetCols) + cx + 0.5f) * finalBlockSize; sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize; ++filledCells; } } if (filledCells <= 0) { return info; } info.active = true; info.x = std::clamp(sumLocalX / static_cast(filledCells), 0.0f, halfW); info.y = std::clamp(sumLocalY / static_cast(filledCells), 0.0f, GRID_H); return info; }; const MagnetInfo leftMagnet = computeMagnet(CoopGame::PlayerSide::Left); const MagnetInfo rightMagnet = computeMagnet(CoopGame::PlayerSide::Right); SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE; SDL_GetRenderDrawBlendMode(renderer, &oldBlend); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); auto drawStarfieldHalf = [&](float originX, const MagnetInfo& magnet) { if (magnet.active) { const float magnetStrength = finalBlockSize * 2.2f; s_inGridStarfield.setMagnetTarget(magnet.x, magnet.y, magnetStrength); } else { s_inGridStarfield.clearMagnetTarget(); } const float jitterAmp = 1.6f; const float tms = static_cast(sparkNow) * 0.001f; const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f; const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f); s_inGridStarfield.draw(renderer, originX + jitterX, gridY + jitterY, 0.22f, true); }; drawStarfieldHalf(leftGridX, leftMagnet); drawStarfieldHalf(rightGridX, rightMagnet); auto updateAndDrawSparkleLayer = [&](std::vector& sparkles, std::vector& impactSparks, float& spawnAcc, const MagnetInfo& magnet, float originX) { if (!paused) { const float spawnInterval = 0.08f; spawnAcc += deltaSeconds; while (spawnAcc >= spawnInterval) { spawnAcc -= spawnInterval; Sparkle s; bool spawnNearPiece = magnet.active && (std::uniform_real_distribution(0.0f, 1.0f)(s_coopSparkRng) > 0.35f); float sx = 0.0f; float sy = 0.0f; if (spawnNearPiece) { float jitterX = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng); float jitterY = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng); sx = std::clamp(magnet.x + jitterX, -finalBlockSize * 2.0f, halfW + finalBlockSize * 2.0f); sy = std::clamp(magnet.y + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f); } else { float side = std::uniform_real_distribution(0.0f, 1.0f)(s_coopSparkRng); const float borderBand = std::max(12.0f, finalBlockSize * 1.0f); if (side < 0.2f) { sx = std::uniform_real_distribution(-borderBand, 0.0f)(s_coopSparkRng); sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_coopSparkRng); } else if (side < 0.4f) { sx = std::uniform_real_distribution(halfW, halfW + borderBand)(s_coopSparkRng); sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_coopSparkRng); } else if (side < 0.6f) { sx = std::uniform_real_distribution(-borderBand, halfW + borderBand)(s_coopSparkRng); sy = std::uniform_real_distribution(-borderBand, 0.0f)(s_coopSparkRng); } else if (side < 0.9f) { sx = std::uniform_real_distribution(0.0f, halfW)(s_coopSparkRng); sy = std::uniform_real_distribution(0.0f, finalBlockSize * 2.0f)(s_coopSparkRng); } else { sx = std::uniform_real_distribution(-borderBand, halfW + borderBand)(s_coopSparkRng); sy = std::uniform_real_distribution(GRID_H, GRID_H + borderBand)(s_coopSparkRng); } } s.x = sx; s.y = sy; float speed = std::uniform_real_distribution(10.0f, 60.0f)(s_coopSparkRng); float ang = std::uniform_real_distribution(-3.14159f, 3.14159f)(s_coopSparkRng); s.vx = std::cos(ang) * speed; s.vy = std::sin(ang) * speed * 0.25f; s.maxLifeMs = std::uniform_real_distribution(350.0f, 900.0f)(s_coopSparkRng); s.lifeMs = s.maxLifeMs; s.size = std::uniform_real_distribution(1.5f, 5.0f)(s_coopSparkRng); if (std::uniform_real_distribution(0.0f, 1.0f)(s_coopSparkRng) < 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_coopSparkRng); sparkles.push_back(s); } } if (!sparkles.empty()) { auto it = sparkles.begin(); while (it != sparkles.end()) { Sparkle& sp = *it; sp.lifeMs -= sparkDeltaMs; if (sp.lifeMs <= 0.0f) { const int burstCount = std::uniform_int_distribution(4, 8)(s_coopSparkRng); for (int bi = 0; bi < burstCount; ++bi) { ImpactSpark ps; ps.x = originX + sp.x + std::uniform_real_distribution(-2.0f, 2.0f)(s_coopSparkRng); ps.y = gridY + sp.y + std::uniform_real_distribution(-2.0f, 2.0f)(s_coopSparkRng); float ang = std::uniform_real_distribution(0.0f, 6.2831853f)(s_coopSparkRng); float speed = std::uniform_real_distribution(10.0f, 120.0f)(s_coopSparkRng); 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_coopSparkRng); ps.lifeMs = ps.maxLifeMs; ps.size = std::max(1.0f, sp.size * 0.5f); ps.color = sp.color; impactSparks.push_back(ps); } it = sparkles.erase(it); continue; } float lifeRatio = sp.lifeMs / sp.maxLifeMs; sp.x += sp.vx * deltaSeconds; sp.y += sp.vy * deltaSeconds; sp.vy *= 0.995f; sp.pulse += deltaSeconds * 8.0f; 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_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha); float half = sp.size * 0.5f; SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size }; SDL_RenderFillRect(renderer, &fr); ++it; } } if (!impactSparks.empty()) { auto it = impactSparks.begin(); while (it != 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 = 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; } } }; updateAndDrawSparkleLayer(s_leftSparkles, s_leftImpactSparks, s_leftSparkleSpawnAcc, leftMagnet, leftGridX); updateAndDrawSparkleLayer(s_rightSparkles, s_rightImpactSparks, s_rightSparkleSpawnAcc, rightMagnet, rightGridX); SDL_SetRenderDrawBlendMode(renderer, oldBlend); } // 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(); for (int y = 0; y < CoopGame::ROWS; ++y) { const auto& rs = rowStates[y]; float rowY = gridY + y * finalBlockSize; if (rs.leftFull && rs.rightFull) { SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45); SDL_FRect fr{gridX, rowY, GRID_W, finalBlockSize}; SDL_RenderFillRect(renderer, &fr); } 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; SDL_FRect fr{x, rowY, w, finalBlockSize}; SDL_RenderFillRect(renderer, &fr); } } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); // Hard-drop impact shake (match classic feel) 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(); const auto& boardRef = game->boardRef(); for (const auto& cell : impactCells) { if (cell.x < 0 || cell.x >= CoopGame::COLS || cell.y < 0 || cell.y >= CoopGame::ROWS) { continue; } int idx = cell.y * CoopGame::COLS + cell.x; impactMask[idx] = 1; impactWeight[idx] = 1.0f; int depth = 0; for (int ny = cell.y + 1; ny < CoopGame::ROWS && depth < 4; ++ny) { if (!boardRef[ny * CoopGame::COLS + cell.x].occupied) { break; } ++depth; int nidx = ny * CoopGame::COLS + cell.x; impactMask[nidx] = 1; float weight = std::max(0.15f, 1.0f - depth * 0.35f); impactWeight[nidx] = std::max(impactWeight[nidx], weight); } } } // Draw settled blocks const auto& board = game->boardRef(); for (int y = 0; y < CoopGame::ROWS; ++y) { float dropOffset = rowDropOffsets[y]; 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 py = gridY + y * finalBlockSize + dropOffset; const int cellIdx = y * CoopGame::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; px += amplitude * std::sin(t * freq); py += amplitude * 0.75f * std::cos(t * (freq + 1.1f)); } drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1); } } // Active pieces (per-side smoothing) auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) { float offsetX = 0.0f; float offsetY = 0.0f; if (smoothScrollEnabled) { const uint64_t seq = game->currentPieceSequence(side); const float targetX = static_cast(game->current(side).x); if (!ss.initialized || ss.seq != seq) { ss.initialized = true; ss.seq = seq; ss.visualX = targetX; // Trigger a short spawn fade so the newly spawned piece visually // fades into the first visible row (like classic mode). SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade; sf.active = true; sf.startTick = nowTicks; sf.durationMs = 200.0f; sf.seq = seq; sf.piece = game->current(side); sf.spawnY = sf.piece.y; sf.tileSize = finalBlockSize; // Note: during the spawn fade we draw the live piece each frame. // If the piece is still above the visible grid, we temporarily pin // it so the topmost filled cell appears at row 0 (no spawn delay), // while still applying smoothing offsets so it starts moving // immediately. sf.targetX = 0.0f; sf.targetY = 0.0f; } else { // Reuse exact horizontal smoothing from single-player constexpr float HORIZONTAL_SMOOTH_MS = 55.0f; const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f); ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor); } offsetX = (ss.visualX - targetX) * finalBlockSize; // Reuse exact single-player fall offset computation (per-side getters) double gravityMs = game->getGravityMs(); if (gravityMs > 0.0) { double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs; double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs); float progress = static_cast(accumulator / effectiveMs); progress = std::clamp(progress, 0.0f, 1.0f); offsetY = progress * finalBlockSize; // Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player) const auto& boardRef = game->boardRef(); const CoopGame::Piece& piece = game->current(side); float maxAllowed = finalBlockSize; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(piece, cx, cy)) continue; int gx = piece.x + cx; int gy = piece.y + cy; if (gx < 0 || gx >= CoopGame::COLS) continue; int testY = gy + 1; int emptyRows = 0; if (testY < 0) { emptyRows -= testY; testY = 0; } while (testY >= 0 && testY < CoopGame::ROWS) { if (boardRef[testY * CoopGame::COLS + gx].occupied) break; ++emptyRows; ++testY; } float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f; maxAllowed = std::min(maxAllowed, cellLimit); } } offsetY = std::min(offsetY, maxAllowed); } } else { ss.initialized = true; ss.seq = game->currentPieceSequence(side); ss.visualX = static_cast(game->current(side).x); } if (Settings::instance().isDebugEnabled()) { float dbg_targetX = static_cast(game->current(side).x); double gMsDbg = game->getGravityMs(); double accDbg = game->getFallAccumulator(side); int softDbg = game->isSoftDropping(side) ? 1 : 0; /* SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d", (side == CoopGame::PlayerSide::Left) ? "L" : "R", (unsigned long long)ss.seq, ss.visualX, dbg_targetX, offsetX, offsetY, gMsDbg, accDbg, softDbg ); */ } return std::pair{ offsetX, offsetY }; }; auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair& offsets) { if (!sf.active) return; // If the piece has already changed, stop the fade. const uint64_t currentSeq = game->currentPieceSequence(side); if (sf.seq != currentSeq) { sf.active = false; return; } const CoopGame::Piece& livePiece = game->current(side); float elapsed = static_cast(nowTicks - sf.startTick); float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f); Uint8 alpha = static_cast(std::lround(255.0f * t)); if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha); int minCy = 4; int maxCy = -1; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(livePiece, cx, cy)) continue; minCy = std::min(minCy, cy); maxCy = std::max(maxCy, cy); } } if (minCy == 4) { minCy = 0; } if (maxCy < 0) { maxCy = 0; } // Pin only when *no* filled cell is visible yet. Using maxCy avoids pinning // 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; float baseY = 0.0f; if (pinToFirstVisibleRow) { // Keep the piece visible (topmost filled cell at row 0), but also // incorporate real y-step progression so the fall accumulator wrapping // doesn't produce a one-row snap. const int dySteps = livePiece.y - sf.spawnY; baseY = (gridY - static_cast(minCy) * sf.tileSize) + static_cast(dySteps) * sf.tileSize + offsets.second; } else { baseY = gridY + static_cast(livePiece.y) * sf.tileSize + offsets.second; } // Draw the live piece (either pinned-to-row0 or at its real position). for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(livePiece, cx, cy)) continue; int pyIdx = livePiece.y + cy; if (!pinToFirstVisibleRow && pyIdx < 0) continue; float px = baseX + static_cast(cx) * sf.tileSize; float py = baseY + static_cast(cy) * sf.tileSize; drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type); } } if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); // End fade after duration, but never stop while we are pinning (otherwise // I can briefly disappear until it becomes visible in the real grid). if (t >= 1.0f && !pinToFirstVisibleRow) { sf.active = false; } }; auto drawPiece = [&](const CoopGame::Piece& p, const std::pair& offsets, bool isGhost) { for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(p, cx, cy)) continue; 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 py = gridY + (float)pyIdx * finalBlockSize + offsets.second; if (isGhost) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20); SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f}; SDL_RenderFillRect(renderer, &rect); SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30); SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f}; SDL_RenderRect(renderer, &border); } else { drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type); } } } }; const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth); const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth); // Draw transient spawn fades (if active) drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left, leftOffsets); drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right, rightOffsets); // Draw classic-style ghost pieces (landing position), grid-aligned. // This intentionally does NOT use smoothing offsets. auto computeGhostPiece = [&](CoopGame::PlayerSide side) { CoopGame::Piece ghostPiece = game->current(side); const auto& boardRef = game->boardRef(); while (true) { CoopGame::Piece testPiece = ghostPiece; testPiece.y++; bool collision = false; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(testPiece, cx, cy)) continue; int gx = testPiece.x + cx; int gy = testPiece.y + cy; if (gy >= CoopGame::ROWS || gx < 0 || gx >= CoopGame::COLS || (gy >= 0 && boardRef[gy * CoopGame::COLS + gx].occupied)) { collision = true; break; } } if (collision) break; } if (collision) break; ghostPiece = testPiece; } return ghostPiece; }; const std::pair ghostOffsets{0.0f, 0.0f}; drawPiece(computeGhostPiece(CoopGame::PlayerSide::Left), ghostOffsets, true); drawPiece(computeGhostPiece(CoopGame::PlayerSide::Right), ghostOffsets, true); // If a spawn fade is active for a side and matches the current piece // sequence, only draw the fade visual and skip the regular piece draw // to avoid a double-draw that appears as a jump when falling starts. if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) { drawPiece(game->current(CoopGame::PlayerSide::Left), leftOffsets, false); } if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) { drawPiece(game->current(CoopGame::PlayerSide::Right), rightOffsets, false); } // 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)); } // Next panels (two) const float nextPanelPad = 12.0f; const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f; const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f; float nextLeftX = gridX + finalBlockSize; float nextRightX = gridX + GRID_W - finalBlockSize - nextPanelW; float nextY = contentStartY + contentOffsetY; auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) { SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH }; if (nextPanelTex) { SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel); } else { drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200}); } // Center piece inside panel int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(piece, cx, cy)) continue; minCx = std::min(minCx, cx); minCy = std::min(minCy, cy); maxCx = std::max(maxCx, cx); maxCy = std::max(maxCy, cy); } } if (maxCx >= minCx && maxCy >= minCy) { float tile = finalBlockSize * 0.8f; float pieceW = (maxCx - minCx + 1) * tile; float pieceH = (maxCy - minCy + 1) * tile; float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile; float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile; for (int cy = 0; cy < 4; ++cy) { for (int cx = 0; cx < 4; ++cx) { if (!CoopGame::cellFilled(piece, cx, cy)) continue; float px = startX + cx * tile; float py = startY + cy * tile; drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type); } } } }; drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left)); drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right)); // Per-player scoreboards (left and right) auto drawPlayerScoreboard = [&](CoopGame::PlayerSide side, float columnLeftX, float columnRightX, const char* title) { const SDL_Color labelColor{255, 220, 0, 255}; const SDL_Color valueColor{255, 255, 255, 255}; const SDL_Color nextColor{80, 255, 120, 255}; // Match classic vertical placement feel 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 statsPanelPadLeft = 40.0f; const float statsPanelPadRight = 34.0f; const float statsPanelPadY = 28.0f; const float textX = columnLeftX + statsPanelPadLeft; char scoreStr[32]; std::snprintf(scoreStr, sizeof(scoreStr), "%d", game->score(side)); char linesStr[16]; std::snprintf(linesStr, sizeof(linesStr), "%03d", game->lines(side)); char levelStr[16]; std::snprintf(levelStr, sizeof(levelStr), "%02d", game->level(side)); // Next level progression (per-player lines) int startLv = game->startLevelBase(); int linesDone = game->lines(side); int firstThreshold = (startLv + 1) * 10; 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]; std::snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext); // Time display (shared session time) int totalSecs = game->elapsed(side); int mins = totalSecs / 60; int secs = totalSecs % 60; char timeStr[16]; std::snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs); struct StatLine { const char* text; float offsetY; float scale; SDL_Color color; }; // Keep offsets aligned with classic spacing std::vector statLines; statLines.reserve(12); statLines.push_back({title, 0.0f, 0.95f, SDL_Color{200, 220, 235, 220}}); statLines.push_back({"SCORE", 30.0f, 1.0f, labelColor}); statLines.push_back({scoreStr, 55.0f, 0.9f, valueColor}); statLines.push_back({"LINES", 100.0f, 1.0f, labelColor}); statLines.push_back({linesStr, 125.0f, 0.9f, valueColor}); statLines.push_back({"LEVEL", 170.0f, 1.0f, labelColor}); statLines.push_back({levelStr, 195.0f, 0.9f, valueColor}); statLines.push_back({"NEXT LVL", 230.0f, 1.0f, labelColor}); statLines.push_back({nextStr, 255.0f, 0.9f, nextColor}); statLines.push_back({"TIME", 295.0f, 1.0f, labelColor}); statLines.push_back({timeStr, 320.0f, 0.9f, valueColor}); // Size the panel like classic: measure the text block and fit the background. 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 + static_cast(textH)); statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast(textW)); } float panelW = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight; float panelH = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f; float panelY = statsContentTop - statsPanelPadY; // Left player is left-aligned in its column; right player is right-aligned. float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX; SDL_FRect panelBg{ panelX, panelY, panelW, panelH }; if (scorePanelTex) { SDL_RenderTexture(renderer, scorePanelTex, nullptr, &panelBg); } else { SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); SDL_RenderFillRect(renderer, &panelBg); } float textDrawX = panelX + statsPanelPadLeft; for (const auto& line : statLines) { pixelFont->draw(renderer, textDrawX, baseY + line.offsetY, line.text, line.scale, line.color); } }; // Nudge panels toward the window edges for tighter symmetry. const float scorePanelEdgeNudge = 20.0f; const float leftColumnLeftX = statsX - scorePanelEdgeNudge; const float leftColumnRightX = leftColumnLeftX + statsW; const float rightColumnLeftX = rightPanelX; const float rightColumnRightX = rightColumnLeftX + statsW + scorePanelEdgeNudge; drawPlayerScoreboard(CoopGame::PlayerSide::Left, leftColumnLeftX, leftColumnRightX, "PLAYER 1"); drawPlayerScoreboard(CoopGame::PlayerSide::Right, rightColumnLeftX, rightColumnRightX, "PLAYER 2"); } 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); }