Files
spacetris/src/main.cpp
Gregor Klevze 6d4f3c54ed Add exit-confirm modal (fullscreen dim, centered P2 text) and keyboard shortcuts
Add an in-game exit confirmation modal for Playing state: ESC opens modal and pauses the game; YES resets and returns to Menu; NO hides modal and resumes.
Draw a full-window translucent dim background (reset viewport) so overlay covers any window size / fullscreen.
Use PressStart2P (pixel P2) font for all modal text and center title/body/button labels using measured text widths.
Add FontAtlas::measure(...) to accurately measure text sizes (used for proper centering).
Ensure popup rendering and mouse hit-testing use the same logical/content-local coordinate math so visuals and clicks align.
Add keyboard shortcuts for modal (Enter = confirm, Esc = cancel) and suppress other gameplay input while modal is active.
Add helper scripts for debug build+run: build-debug-and-run.ps1 and build-debug-and-run.bat.
Minor fixes to related rendering & state wiring; verified Debug build completes and modal behavior in runtime.
2025-08-16 19:40:23 +02:00

1767 lines
83 KiB
C++

// main.cpp - Application orchestration (initialization, loop, UI states)
// High-level only: delegates Tetris logic, scores, background, font rendering.
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <string>
#include <cstdio>
#include <algorithm>
#include <array>
#include <vector>
#include <random>
#include <cmath>
#include <cstdlib>
#include <memory>
#include "audio/Audio.h"
#include "audio/SoundEffect.h"
#include "gameplay/Game.h"
#include "persistence/Scores.h"
#include "graphics/Starfield.h"
#include "Starfield3D.h"
#include "graphics/Font.h"
#include "gameplay/LineEffect.h"
#include "states/State.h"
#include "states/LoadingState.h"
#include "states/MenuState.h"
#include "states/PlayingState.h"
#include "audio/MenuWrappers.h"
// Debug logging removed: no-op in this build (previously LOG_DEBUG)
// Font rendering now handled by FontAtlas
// ---------- Game config ----------
static constexpr int LOGICAL_W = 1200;
static constexpr int LOGICAL_H = 1000;
static constexpr int WELL_W = Game::COLS * Game::TILE;
static constexpr int WELL_H = Game::ROWS * Game::TILE;
// Piece types now declared in Game.h
// Scores now managed by ScoreManager
// 4x4 shapes encoded as 16-bit bitmasks per rotation (row-major 4x4).
// Bit 0 = (x=0,y=0), Bit 1 = (1,0) ... Bit 15 = (3,3)
// Shapes & game logic now in Game.cpp
// (removed inline shapes)
// Piece struct now in Game.h
// Game struct replaced by Game class
static const std::array<SDL_Color, PIECE_COUNT + 1> COLORS = {{
SDL_Color{20, 20, 26, 255}, // 0 empty
SDL_Color{0, 255, 255, 255}, // I
SDL_Color{255, 255, 0, 255}, // O
SDL_Color{160, 0, 255, 255}, // T
SDL_Color{0, 255, 0, 255}, // S
SDL_Color{255, 0, 0, 255}, // Z
SDL_Color{0, 0, 255, 255}, // J
SDL_Color{255, 160, 0, 255}, // L
}};
static void drawRect(SDL_Renderer *r, float x, float y, float w, float h, SDL_Color c)
{
SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a);
SDL_FRect fr{x, y, w, h};
SDL_RenderFillRect(r, &fr);
}
// ...existing code...
// -----------------------------------------------------------------------------
// Enhanced Button Drawing
// -----------------------------------------------------------------------------
static void drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
const std::string& label, bool isHovered, bool isSelected = false) {
SDL_Color bgColor = isHovered ? SDL_Color{120, 150, 240, 255} : SDL_Color{80, 110, 200, 255};
if (isSelected) bgColor = {160, 190, 255, 255};
float x = cx - w/2;
float y = cy - h/2;
// Draw button background with border
drawRect(renderer, x-2, y-2, w+4, h+4, {60, 80, 140, 255}); // Border
drawRect(renderer, x, y, w, h, bgColor); // Background
// Draw button text centered
float textScale = 1.5f;
float textX = x + (w - label.length() * 12 * textScale) / 2;
float textY = y + (h - 20 * textScale) / 2;
font.draw(renderer, textX, textY, label, textScale, {255, 255, 255, 255});
}
// External wrapper for enhanced button so other translation units can call it.
void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
const std::string& label, bool isHovered, bool isSelected) {
drawEnhancedButton(renderer, font, cx, cy, w, h, label, isHovered, isSelected);
}
// Popup wrappers
// Forward declarations for popup functions defined later in this file
static void drawLevelSelectionPopup(SDL_Renderer* renderer, FontAtlas& font, SDL_Texture* bgTex, int selectedLevel);
static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled);
void menu_drawLevelSelectionPopup(SDL_Renderer* renderer, FontAtlas& font, SDL_Texture* bgTex, int selectedLevel) {
drawLevelSelectionPopup(renderer, font, bgTex, selectedLevel);
}
void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled) {
drawSettingsPopup(renderer, font, musicEnabled);
}
// Simple rounded menu button drawer used by MenuState (keeps visual parity with JS)
void menu_drawMenuButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
const std::string& label, SDL_Color bgColor, SDL_Color borderColor) {
float x = cx - w/2;
float y = cy - h/2;
drawRect(renderer, x-6, y-6, w+12, h+12, borderColor);
drawRect(renderer, x-4, y-4, w+8, h+8, {255,255,255,255});
drawRect(renderer, x, y, w, h, bgColor);
float textScale = 1.6f;
float approxCharW = 12.0f * textScale;
float textW = label.length() * approxCharW;
float tx = x + (w - textW) / 2.0f;
float ty = y + (h - 20.0f * textScale) / 2.0f;
font.draw(renderer, tx+2, ty+2, label, textScale, {0,0,0,180});
font.draw(renderer, tx, ty, label, textScale, {255,255,255,255});
}
// -----------------------------------------------------------------------------
// Block Drawing Functions
// -----------------------------------------------------------------------------
static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) {
if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) {
// Debug: print why we're falling back
if (!blocksTex) {
static bool printed = false;
if (!printed) {
(void)0;
printed = true;
}
}
// 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);
}
static void drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost = false) {
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;
float py = oy + (piece.y + cy) * tileSize;
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);
}
}
}
}
}
static void 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;
// Center the piece in the preview area
float offsetX = 0, offsetY = 0;
if (pieceType == I) { offsetX = tileSize * 0.5f; } // I-piece centering
else if (pieceType == O) { offsetX = tileSize * 0.5f; } // O-piece centering
// Use semi-transparent alpha for preview blocks
Uint8 previewAlpha = 180; // Change this value for more/less transparency
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;
drawBlockTexture(renderer, blocksTex, px, py, tileSize, pieceType);
}
}
}
SDL_SetTextureAlphaMod(blocksTex, 255); // Reset alpha after drawing
}
// -----------------------------------------------------------------------------
// Popup Drawing Functions
// -----------------------------------------------------------------------------
static void drawLevelSelectionPopup(SDL_Renderer* renderer, FontAtlas& font, SDL_Texture* bgTex, int selectedLevel) {
// Popup dims scale with logical size for responsiveness
float popupW = std::min(760.0f, LOGICAL_W * 0.75f);
float popupH = std::min(520.0f, LOGICAL_H * 0.7f);
float popupX = (LOGICAL_W - popupW) / 2.0f;
float popupY = (LOGICAL_H - popupH) / 2.0f;
// Draw the background picture stretched to full logical viewport if available
if (bgTex) {
// Dim the background by rendering it then overlaying a semi-transparent black rect
SDL_FRect dst{0, 0, (float)LOGICAL_W, (float)LOGICAL_H};
SDL_RenderTexture(renderer, bgTex, nullptr, &dst);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
SDL_FRect dim{0,0,(float)LOGICAL_W,(float)LOGICAL_H};
SDL_RenderFillRect(renderer, &dim);
} else {
// Fallback to semi-transparent overlay
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180);
SDL_FRect overlay{0, 0, (float)LOGICAL_W, (float)LOGICAL_H};
SDL_RenderFillRect(renderer, &overlay);
}
// Popup panel with border and subtle background
drawRect(renderer, popupX-6, popupY-6, popupW+12, popupH+12, {90, 110, 140, 200}); // outer border
drawRect(renderer, popupX-3, popupY-3, popupW+6, popupH+6, {30, 38, 60, 220}); // inner border
drawRect(renderer, popupX, popupY, popupW, popupH, {18, 22, 34, 235}); // panel
// Title (use retro pixel font)
font.draw(renderer, popupX + 28, popupY + 18, "SELECT STARTING LEVEL", 2.4f, {255, 220, 0, 255});
// Grid layout for levels: 4 columns x 5 rows
int cols = 4, rows = 5;
float padding = 24.0f;
float gridW = popupW - padding * 2;
float gridH = popupH - 120.0f; // leave space for title and instructions
float cellW = gridW / cols;
float cellH = std::min(80.0f, gridH / rows - 12.0f);
float gridStartX = popupX + padding;
float gridStartY = popupY + 70;
for (int level = 0; level < 20; ++level) {
int row = level / cols;
int col = level % cols;
float cx = gridStartX + col * cellW;
float cy = gridStartY + row * (cellH + 12.0f);
bool isSelected = (level == selectedLevel);
SDL_Color bg = isSelected ? SDL_Color{255, 220, 0, 255} : SDL_Color{70, 85, 120, 240};
SDL_Color fg = isSelected ? SDL_Color{0, 0, 0, 255} : SDL_Color{240, 240, 245, 255};
// Button background
drawRect(renderer, cx + 8, cy, cellW - 16, cellH, bg);
// Level label centered
char levelStr[8]; snprintf(levelStr, sizeof(levelStr), "%d", level);
float tx = cx + (cellW / 2.0f) - (6.0f * 1.8f); // rough centering
float ty = cy + (cellH / 2.0f) - 10.0f;
font.draw(renderer, tx, ty, levelStr, 1.8f, fg);
}
// Instructions under grid
font.draw(renderer, popupX + 28, popupY + popupH - 40, "CLICK A LEVEL TO SELECT • ESC = CANCEL", 1.0f, {200,200,220,255});
}
static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled) {
float popupW = 350, popupH = 260;
float popupX = (LOGICAL_W - popupW) / 2;
float popupY = (LOGICAL_H - popupH) / 2;
// Semi-transparent overlay
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 128);
SDL_FRect overlay{0, 0, LOGICAL_W, LOGICAL_H};
SDL_RenderFillRect(renderer, &overlay);
// Popup background
drawRect(renderer, popupX-4, popupY-4, popupW+8, popupH+8, {100, 120, 160, 255}); // Border
drawRect(renderer, popupX, popupY, popupW, popupH, {40, 50, 70, 255}); // Background
// Title
font.draw(renderer, popupX + 20, popupY + 20, "SETTINGS", 2.0f, {255, 220, 0, 255});
// Music toggle
font.draw(renderer, popupX + 20, popupY + 70, "MUSIC:", 1.5f, {255, 255, 255, 255});
const char* musicStatus = musicEnabled ? "ON" : "OFF";
SDL_Color musicColor = musicEnabled ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255};
font.draw(renderer, popupX + 120, popupY + 70, musicStatus, 1.5f, musicColor);
// Sound effects toggle
font.draw(renderer, popupX + 20, popupY + 100, "SOUND FX:", 1.5f, {255, 255, 255, 255});
const char* soundStatus = SoundEffectManager::instance().isEnabled() ? "ON" : "OFF";
SDL_Color soundColor = SoundEffectManager::instance().isEnabled() ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255};
font.draw(renderer, popupX + 140, popupY + 100, soundStatus, 1.5f, soundColor);
// Instructions
font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
font.draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
font.draw(renderer, popupX + 20, popupY + 190, "N = PLAY LETS_GO", 1.0f, {200, 200, 220, 255});
font.draw(renderer, popupX + 20, popupY + 210, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
}
// -----------------------------------------------------------------------------
// Starfield effect for background
// -----------------------------------------------------------------------------
// Starfield now managed by Starfield class
// State manager integration (scaffolded in StateManager.h)
#include "core/StateManager.h"
// -----------------------------------------------------------------------------
// Intro/Menu state variables
// -----------------------------------------------------------------------------
static double logoAnimCounter = 0.0;
static bool showLevelPopup = false;
static bool showSettingsPopup = false;
static bool showExitConfirmPopup = false;
static bool musicEnabled = true;
static int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
// -----------------------------------------------------------------------------
// Tetris Block Fireworks for intro animation (block particles)
// Forward declare block render helper used by particles
static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType);
// -----------------------------------------------------------------------------
struct BlockParticle {
float x, y, vx, vy, size, alpha, decay;
int blockType; // 0..6
BlockParticle(float sx, float sy)
: x(sx), y(sy) {
float angle = (rand() % 628) / 100.0f; // 0..2pi
float speed = 1.5f + (rand() % 350) / 100.0f; // ~1.5..5.0
vx = std::cos(angle) * speed;
vy = std::sin(angle) * speed;
size = 6.0f + (rand() % 50) / 10.0f; // 6..11 px
alpha = 1.0f;
decay = 0.012f + (rand() % 200) / 10000.0f; // 0.012..0.032
blockType = rand() % 7; // choose a tetris color
}
bool update() {
vx *= 0.985f; // friction
vy = vy * 0.985f + 0.07f; // gravity
x += vx;
y += vy;
alpha -= decay;
size = std::max(2.0f, size - 0.04f);
return alpha > 0.02f;
}
};
struct TetrisFirework {
std::vector<BlockParticle> particles;
int mode = 0; // 0=random,1=red,2=green,3=palette
TetrisFirework(float x, float y) {
mode = rand() % 4;
int particleCount = 30 + rand() % 25; // 30-55 particles
particles.reserve(particleCount);
for (int i = 0; i < particleCount; ++i) particles.emplace_back(x, y);
}
bool update() {
for (auto it = particles.begin(); it != particles.end();) {
if (!it->update()) it = particles.erase(it); else ++it;
}
return !particles.empty();
}
// Drawing is handled by drawFireworks_impl which accepts the texture to use.
};
static std::vector<TetrisFirework> fireworks;
static Uint64 lastFireworkTime = 0;
// -----------------------------------------------------------------------------
// Fireworks Management
// -----------------------------------------------------------------------------
static void updateFireworks(double frameMs) {
Uint64 now = SDL_GetTicks();
// Randomly spawn new block fireworks (2% chance per frame), bias to lower-right
if (fireworks.size() < 5 && (rand() % 100) < 2) {
float x = LOGICAL_W * 0.55f + float(rand() % int(LOGICAL_W * 0.35f));
float y = LOGICAL_H * 0.80f + float(rand() % int(LOGICAL_H * 0.15f));
fireworks.emplace_back(x, y);
lastFireworkTime = now;
}
// Update existing fireworks
for (auto it = fireworks.begin(); it != fireworks.end();) {
if (!it->update()) {
it = fireworks.erase(it);
} else {
++it;
}
}
}
// Primary implementation that accepts a texture pointer
static void drawFireworks_impl(SDL_Renderer* renderer, SDL_Texture* blocksTexture) {
for (auto& f : fireworks) {
// Particle draw uses the texture pointer passed into drawBlockTexture calls from f.draw
// We'll set a thread-local-ish variable by passing the texture as an argument to draw
// routines or using the provided texture in the particle's draw path.
// For simplicity, the particle draw function below will reference a global symbol
// via an argument — we adapt by providing the texture when calling drawBlockTexture.
// Implementation: call a small lambda that temporarily binds the texture for drawBlockTexture.
struct Drawer { SDL_Renderer* r; SDL_Texture* tex; void drawParticle(struct BlockParticle& p) {
if (tex) {
Uint8 prevA = 255;
SDL_GetTextureAlphaMod(tex, &prevA);
Uint8 setA = Uint8(std::max(0.0f, std::min(1.0f, p.alpha)) * 255.0f);
SDL_SetTextureAlphaMod(tex, setA);
// Note: color modulation will be applied by callers of drawBlockTexture where needed
// but we mimic behavior from previous implementation by leaving color mod as default.
drawBlockTexture(r, tex, p.x - p.size * 0.5f, p.y - p.size * 0.5f, p.size, p.blockType);
SDL_SetTextureAlphaMod(tex, prevA);
SDL_SetTextureColorMod(tex, 255, 255, 255);
} else {
SDL_SetRenderDrawColor(r, 255, 255, 255, Uint8(p.alpha * 255));
SDL_FRect rect{p.x - p.size/2, p.y - p.size/2, p.size, p.size};
SDL_RenderFillRect(r, &rect);
}
}
} drawer{renderer, blocksTexture};
for (auto &p : f.particles) {
drawer.drawParticle(p);
}
}
}
// External wrappers for use by other translation units (MenuState)
// Expect callers to pass the blocks texture via StateContext so we avoid globals.
void menu_drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) { drawFireworks_impl(renderer, blocksTex); }
void menu_updateFireworks(double frameMs) { updateFireworks(frameMs); }
double menu_getLogoAnimCounter() { return logoAnimCounter; }
int menu_getHoveredButton() { return hoveredButton; }
int main(int, char **)
{
// Initialize random seed for fireworks
srand(static_cast<unsigned int>(SDL_GetTicks()));
int sdlInitRes = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO);
if (sdlInitRes < 0)
{
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed: %s", SDL_GetError());
return 1;
}
int ttfInitRes = TTF_Init();
if (ttfInitRes < 0)
{
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "TTF_Init failed");
SDL_Quit();
return 1;
}
SDL_Window *window = SDL_CreateWindow("Tetris (SDL3)", LOGICAL_W, LOGICAL_H, SDL_WINDOW_RESIZABLE);
if (!window)
{
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateWindow failed: %s", SDL_GetError());
TTF_Quit();
SDL_Quit();
return 1;
}
SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr);
if (!renderer)
{
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError());
SDL_DestroyWindow(window);
TTF_Quit();
SDL_Quit();
return 1;
}
SDL_SetRenderVSync(renderer, 1);
FontAtlas font;
font.init("FreeSans.ttf", 24);
// Load PressStart2P font for loading screen and retro UI elements
FontAtlas pixelFont;
pixelFont.init("assets/fonts/PressStart2P-Regular.ttf", 16);
ScoreManager scores;
scores.load();
Starfield starfield;
starfield.init(200, LOGICAL_W, LOGICAL_H);
Starfield3D starfield3D;
starfield3D.init(LOGICAL_W, LOGICAL_H, 200);
// Initialize line clearing effects
LineEffect lineEffect;
lineEffect.init(renderer);
// Load logo using native SDL BMP loading
SDL_Texture *logoTex = nullptr;
SDL_Surface* logoSurface = SDL_LoadBMP("assets/images/logo.bmp");
if (logoSurface) {
(void)0;
logoTex = SDL_CreateTextureFromSurface(renderer, logoSurface);
SDL_DestroySurface(logoSurface);
} else {
(void)0;
}
// Load small logo (used by Menu to show whole logo)
SDL_Texture *logoSmallTex = nullptr;
SDL_Surface* logoSmallSurface = SDL_LoadBMP("assets/images/logo_small.bmp");
int logoSmallW = 0, logoSmallH = 0;
if (logoSmallSurface) {
// capture surface size before creating the texture (avoids SDL_QueryTexture)
logoSmallW = logoSmallSurface->w;
logoSmallH = logoSmallSurface->h;
logoSmallTex = SDL_CreateTextureFromSurface(renderer, logoSmallSurface);
SDL_DestroySurface(logoSmallSurface);
} else {
// fallback: leave logoSmallTex null so MenuState will use large logo
(void)0;
}
// Load background using native SDL BMP loading
SDL_Texture *backgroundTex = nullptr;
SDL_Surface* backgroundSurface = SDL_LoadBMP("assets/images/main_background.bmp");
if (backgroundSurface) {
(void)0;
backgroundTex = SDL_CreateTextureFromSurface(renderer, backgroundSurface);
SDL_DestroySurface(backgroundSurface);
} else {
(void)0;
}
// Note: `backgroundTex` is owned by main and passed into `StateContext::backgroundTex` below.
// States should render using `ctx.backgroundTex` rather than accessing globals.
// Level background caching system
SDL_Texture *levelBackgroundTex = nullptr;
SDL_Texture *nextLevelBackgroundTex = nullptr; // used during fade transitions
int cachedLevel = -1; // Track which level background is currently cached
float levelFadeAlpha = 0.0f; // 0..1 blend factor where 1 means next fully visible
const float LEVEL_FADE_DURATION = 3500.0f; // ms for fade transition (3.5s)
float levelFadeElapsed = 0.0f;
// Load blocks texture using native SDL BMP loading
SDL_Texture *blocksTex = nullptr;
SDL_Surface* blocksSurface = SDL_LoadBMP("assets/images/blocks90px_001.bmp");
if (blocksSurface) {
(void)0;
blocksTex = SDL_CreateTextureFromSurface(renderer, blocksSurface);
SDL_DestroySurface(blocksSurface);
} else {
(void)0;
}
// No global exposure of blocksTex; states receive textures via StateContext.
if (!blocksTex) {
(void)0;
// Create a 630x90 texture (7 blocks * 90px each)
blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90);
if (blocksTex) {
// Set texture as render target and draw colored blocks
SDL_SetRenderTarget(renderer, blocksTex);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
// Draw each block type with its color
for (int i = 0; i < PIECE_COUNT; ++i) {
SDL_Color color = COLORS[i + 1];
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, 255);
SDL_FRect rect = {float(i * 90 + 4), 4, 82, 82}; // 4px padding, 82x82 block
SDL_RenderFillRect(renderer, &rect);
// Add a highlight effect
SDL_SetRenderDrawColor(renderer,
(Uint8)std::min(255, color.r + 30),
(Uint8)std::min(255, color.g + 30),
(Uint8)std::min(255, color.b + 30), 255);
SDL_FRect highlight = {float(i * 90 + 4), 4, 82, 20};
SDL_RenderFillRect(renderer, &highlight);
}
// Reset render target
SDL_SetRenderTarget(renderer, nullptr);
(void)0;
} else {
std::fprintf(stderr, "Failed to create programmatic texture: %s\n", SDL_GetError());
}
} else {
(void)0;
}
// Provide the blocks sheet to the fireworks system through StateContext (no globals).
// Default start level selection: 0
int startLevelSelection = 0;
Game game(startLevelSelection);
// Initialize sound effects system
SoundEffectManager::instance().init();
// Load sound effects
SoundEffectManager::instance().loadSound("clear_line", "assets/music/clear_line.wav");
// Load voice lines for line clears using WAV files (with MP3 fallback)
std::vector<std::string> doubleSounds = {"nice_combo", "you_fire", "well_played", "keep_that_ryhtm"};
std::vector<std::string> tripleSounds = {"great_move", "smooth_clear", "impressive", "triple_strike"};
std::vector<std::string> tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"};
// Helper function to load sound with WAV/MP3 fallback and file existence check
auto loadSoundWithFallback = [&](const std::string& id, const std::string& baseName) {
std::string wavPath = "assets/music/" + baseName + ".wav";
std::string mp3Path = "assets/music/" + baseName + ".mp3";
// Check if WAV file exists first
SDL_IOStream* wavFile = SDL_IOFromFile(wavPath.c_str(), "rb");
if (wavFile) {
SDL_CloseIO(wavFile);
if (SoundEffectManager::instance().loadSound(id, wavPath)) {
(void)0;
return;
}
}
// Fallback to MP3 if WAV doesn't exist or fails to load
SDL_IOStream* mp3File = SDL_IOFromFile(mp3Path.c_str(), "rb");
if (mp3File) {
SDL_CloseIO(mp3File);
if (SoundEffectManager::instance().loadSound(id, mp3Path)) {
(void)0;
return;
}
}
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load sound: %s (tried both WAV and MP3)", id.c_str());
};
loadSoundWithFallback("nice_combo", "nice_combo");
loadSoundWithFallback("you_fire", "you_fire");
loadSoundWithFallback("well_played", "well_played");
loadSoundWithFallback("keep_that_ryhtm", "keep_that_ryhtm");
loadSoundWithFallback("great_move", "great_move");
loadSoundWithFallback("smooth_clear", "smooth_clear");
loadSoundWithFallback("impressive", "impressive");
loadSoundWithFallback("triple_strike", "triple_strike");
loadSoundWithFallback("amazing", "amazing");
loadSoundWithFallback("you_re_unstoppable", "you_re_unstoppable");
loadSoundWithFallback("boom_tetris", "boom_tetris");
loadSoundWithFallback("wonderful", "wonderful");
loadSoundWithFallback("lets_go", "lets_go"); // For level up
// Set up sound effect callbacks
game.setSoundCallback([&](int linesCleared) {
// Play basic line clear sound first
SoundEffectManager::instance().playSound("clear_line", 1.0f); // Increased volume
// Then play voice line based on number of lines cleared
if (linesCleared == 2) {
SoundEffectManager::instance().playRandomSound(doubleSounds, 1.0f); // Increased volume
} else if (linesCleared == 3) {
SoundEffectManager::instance().playRandomSound(tripleSounds, 1.0f); // Increased volume
} else if (linesCleared == 4) {
SoundEffectManager::instance().playRandomSound(tetrisSounds, 1.0f); // Increased volume
}
// Single line clears just play the basic clear sound (no voice in JS version)
});
game.setLevelUpCallback([&](int newLevel) {
// Play level up sound
SoundEffectManager::instance().playSound("lets_go", 1.0f); // Increased volume
});
AppState state = AppState::Loading;
double loadingProgress = 0.0;
Uint64 loadStart = SDL_GetTicks();
bool running = true, isFullscreen = false;
bool leftHeld = false, rightHeld = false;
double moveTimerMs = 0;
const double DAS = 170.0, ARR = 40.0;
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
float logicalScale = 1.f;
Uint64 lastMs = SDL_GetTicks();
bool musicStarted = false;
bool musicLoaded = false;
int currentTrackLoading = 0;
int totalTracks = 0; // Will be set dynamically based on actual files
// Instantiate state manager
StateManager stateMgr(state);
// Prepare shared context for states
StateContext ctx{};
// Allow states to access the state manager for transitions
ctx.stateManager = &stateMgr;
ctx.game = &game;
ctx.scores = &scores;
ctx.starfield = &starfield;
ctx.starfield3D = &starfield3D;
ctx.font = &font;
ctx.pixelFont = &pixelFont;
ctx.lineEffect = &lineEffect;
ctx.logoTex = logoTex;
ctx.logoSmallTex = logoSmallTex;
ctx.logoSmallW = logoSmallW;
ctx.logoSmallH = logoSmallH;
ctx.backgroundTex = backgroundTex;
ctx.blocksTex = blocksTex;
ctx.musicEnabled = &musicEnabled;
ctx.startLevelSelection = &startLevelSelection;
ctx.hoveredButton = &hoveredButton;
ctx.showLevelPopup = &showLevelPopup;
ctx.showSettingsPopup = &showSettingsPopup;
ctx.showExitConfirmPopup = &showExitConfirmPopup;
// Instantiate state objects
auto loadingState = std::make_unique<LoadingState>(ctx);
auto menuState = std::make_unique<MenuState>(ctx);
auto playingState = std::make_unique<PlayingState>(ctx);
// Register handlers and lifecycle hooks
stateMgr.registerHandler(AppState::Loading, [&](const SDL_Event& e){ loadingState->handleEvent(e); });
stateMgr.registerOnEnter(AppState::Loading, [&](){ loadingState->onEnter(); });
stateMgr.registerOnExit(AppState::Loading, [&](){ loadingState->onExit(); });
stateMgr.registerHandler(AppState::Menu, [&](const SDL_Event& e){ menuState->handleEvent(e); });
stateMgr.registerOnEnter(AppState::Menu, [&](){ menuState->onEnter(); });
stateMgr.registerOnExit(AppState::Menu, [&](){ menuState->onExit(); });
// Combined Playing state handler: run playingState handler and inline gameplay mapping
stateMgr.registerHandler(AppState::Playing, [&](const SDL_Event& e){
// First give the PlayingState a chance to handle the event
playingState->handleEvent(e);
// Then perform inline gameplay mappings (gravity/rotation/hard-drop/hold)
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (!game.isPaused()) {
if (e.key.scancode == SDL_SCANCODE_SPACE) {
game.hardDrop();
}
else if (e.key.scancode == SDL_SCANCODE_UP) {
game.rotate(+1);
}
else if (e.key.scancode == SDL_SCANCODE_Z || (e.key.mod & SDL_KMOD_SHIFT)) {
game.rotate(-1);
}
else if (e.key.scancode == SDL_SCANCODE_C || (e.key.mod & SDL_KMOD_CTRL)) {
game.holdCurrent();
}
}
}
});
stateMgr.registerOnEnter(AppState::Playing, [&](){ playingState->onEnter(); });
stateMgr.registerOnExit(AppState::Playing, [&](){ playingState->onExit(); });
// Playing, LevelSelect and GameOver currently use inline logic in main; we'll migrate later
while (running)
{
int winW = 0, winH = 0;
SDL_GetWindowSize(window, &winW, &winH);
// Use the full window for the viewport, scale to fit content
logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H);
if (logicalScale <= 0)
logicalScale = 1.f;
// Fill the entire window with our viewport
logicalVP.w = winW;
logicalVP.h = winH;
logicalVP.x = 0;
logicalVP.y = 0;
// --- Events ---
SDL_Event e;
while (SDL_PollEvent(&e))
{
if (e.type == SDL_EVENT_QUIT)
running = false;
else {
// Route event to state manager handlers for per-state logic
stateMgr.handleEvent(e);
// Global key toggles (applies regardless of state)
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (e.key.scancode == SDL_SCANCODE_M)
{
Audio::instance().toggleMute();
musicEnabled = !musicEnabled;
}
if (e.key.scancode == SDL_SCANCODE_S)
{
// Toggle sound effects
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
}
if (e.key.scancode == SDL_SCANCODE_N)
{
// Test sound effects - play lets_go.wav specifically
SoundEffectManager::instance().playSound("lets_go", 1.0f);
}
if (e.key.key == SDLK_F11 || (e.key.key == SDLK_RETURN && (e.key.mod & SDL_KMOD_ALT)))
{
isFullscreen = !isFullscreen;
SDL_SetWindowFullscreen(window, isFullscreen ? SDL_WINDOW_FULLSCREEN : 0);
}
}
// Mouse handling remains in main loop for UI interactions
if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN)
{
float mx = (float)e.button.x, my = (float)e.button.y;
if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h)
{
float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale;
if (state == AppState::Menu)
{
// Compute content offsets (match MenuState centering)
float contentW = LOGICAL_W * logicalScale;
float contentH = LOGICAL_H * logicalScale;
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
if (showLevelPopup) {
// Handle level selection popup clicks
float popupW = 400, popupH = 300;
float popupX = (LOGICAL_W - popupW) / 2;
float popupY = (LOGICAL_H - popupH) / 2;
if (lx >= popupX && lx <= popupX + popupW && ly >= popupY && ly <= popupY + popupH) {
// Click inside popup - check level grid
float gridStartX = popupX + 50;
float gridStartY = popupY + 70;
float cellW = 70, cellH = 35;
if (lx >= gridStartX && ly >= gridStartY) {
int col = int((lx - gridStartX) / cellW);
int row = int((ly - gridStartY) / cellH);
if (col >= 0 && col < 4 && row >= 0 && row < 5) {
int selectedLevel = row * 4 + col;
if (selectedLevel < 20) {
startLevelSelection = selectedLevel;
showLevelPopup = false;
}
}
}
} else {
// Click outside popup - close it
showLevelPopup = false;
}
} else if (showSettingsPopup) {
// Click anywhere closes settings popup
showSettingsPopup = false;
} else {
// Responsive Main menu buttons (match MenuState layout)
bool isSmall = ((LOGICAL_W * logicalScale) < 700.0f);
float btnW = isSmall ? (LOGICAL_W * 0.4f) : 300.0f;
float btnH = isSmall ? 60.0f : 70.0f;
float btnCX = LOGICAL_W * 0.5f + contentOffsetX;
const float btnYOffset = 40.0f; // must match MenuState offset
float btnCY = LOGICAL_H * 0.86f + contentOffsetY + btnYOffset;
SDL_FRect playBtn{btnCX - btnW * 0.6f - btnW/2.0f, btnCY - btnH/2.0f, btnW, btnH};
SDL_FRect levelBtn{btnCX + btnW * 0.6f - btnW/2.0f, btnCY - btnH/2.0f, btnW, btnH};
if (lx >= playBtn.x && lx <= playBtn.x + playBtn.w && ly >= playBtn.y && ly <= playBtn.y + playBtn.h)
{
// Reset the game first with the chosen start level so HUD and
// Playing state see the correct 0-based level immediately.
game.reset(startLevelSelection);
state = AppState::Playing;
stateMgr.setState(state);
}
else if (lx >= levelBtn.x && lx <= levelBtn.x + levelBtn.w && ly >= levelBtn.y && ly <= levelBtn.y + levelBtn.h)
{
showLevelPopup = true;
}
// Settings button (gear icon area - top right)
SDL_FRect settingsBtn{LOGICAL_W - 60, 10, 50, 30};
if (lx >= settingsBtn.x && lx <= settingsBtn.x + settingsBtn.w && ly >= settingsBtn.y && ly <= settingsBtn.y + settingsBtn.h)
{
showSettingsPopup = true;
}
}
}
else if (state == AppState::LevelSelect)
startLevelSelection = (startLevelSelection + 1) % 20;
else if (state == AppState::GameOver) {
state = AppState::Menu;
stateMgr.setState(state);
}
else if (state == AppState::Playing && showExitConfirmPopup) {
// Convert mouse to logical coordinates and to content-local coords
float lx = (mx - logicalVP.x) / logicalScale;
float ly = (my - logicalVP.y) / logicalScale;
// Compute content offsets (same as in render path)
float contentW = LOGICAL_W * logicalScale;
float contentH = LOGICAL_H * logicalScale;
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
// Map to content-local logical coords (what drawing code uses)
float localX = lx - contentOffsetX;
float localY = ly - contentOffsetY;
// Popup rect in logical coordinates (content-local)
float popupW = 420, popupH = 180;
float popupX = (LOGICAL_W - popupW) / 2;
float popupY = (LOGICAL_H - popupH) / 2;
if (localX >= popupX && localX <= popupX + popupW && localY >= popupY && localY <= popupY + popupH) {
// Inside popup: two buttons Yes / No
float btnW = 140, btnH = 46;
float yesX = popupX + popupW * 0.25f - btnW/2.0f;
float noX = popupX + popupW * 0.75f - btnW/2.0f;
float btnY = popupY + popupH - 60;
if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) {
// Yes -> go back to menu
showExitConfirmPopup = false;
game.reset(startLevelSelection);
state = AppState::Menu;
stateMgr.setState(state);
}
else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) {
// No -> close popup and resume
showExitConfirmPopup = false;
game.setPaused(false);
}
} else {
// Click outside popup: cancel
showExitConfirmPopup = false;
game.setPaused(false);
}
}
}
}
else if (e.type == SDL_EVENT_MOUSE_MOTION)
{
float mx = (float)e.motion.x, my = (float)e.motion.y;
if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h)
{
float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale;
if (state == AppState::Menu && !showLevelPopup && !showSettingsPopup)
{
// Compute content offsets and responsive buttons (match MenuState)
float contentW = LOGICAL_W * logicalScale;
float contentH = LOGICAL_H * logicalScale;
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
bool isSmall = ((LOGICAL_W * logicalScale) < 700.0f);
float btnW = isSmall ? (LOGICAL_W * 0.4f) : 300.0f;
float btnH = isSmall ? 60.0f : 70.0f;
float btnCX = LOGICAL_W * 0.5f + contentOffsetX;
const float btnYOffset = 40.0f; // must match MenuState offset
float btnCY = LOGICAL_H * 0.86f + contentOffsetY + btnYOffset;
SDL_FRect playBtn{btnCX - btnW * 0.6f - btnW/2.0f, btnCY - btnH/2.0f, btnW, btnH};
SDL_FRect levelBtn{btnCX + btnW * 0.6f - btnW/2.0f, btnCY - btnH/2.0f, btnW, btnH};
hoveredButton = -1;
if (lx >= playBtn.x && lx <= playBtn.x + playBtn.w && ly >= playBtn.y && ly <= playBtn.y + playBtn.h)
hoveredButton = 0;
else if (lx >= levelBtn.x && lx <= levelBtn.x + levelBtn.w && ly >= levelBtn.y && ly <= levelBtn.y + levelBtn.h)
hoveredButton = 1;
}
}
}
}
}
// --- Timing ---
Uint64 now = SDL_GetTicks();
double frameMs = double(now - lastMs);
lastMs = now;
const bool *ks = SDL_GetKeyboardState(nullptr);
bool left = state == AppState::Playing && ks[SDL_SCANCODE_LEFT];
bool right = state == AppState::Playing && ks[SDL_SCANCODE_RIGHT];
bool down = state == AppState::Playing && ks[SDL_SCANCODE_DOWN];
// Inform game about soft-drop state for scoring parity (1 point per cell when holding Down)
if (state == AppState::Playing)
game.setSoftDropping(down && !game.isPaused());
else
game.setSoftDropping(false);
// Handle DAS/ARR
int moveDir = 0;
if (left && !right)
moveDir = -1;
else if (right && !left)
moveDir = +1;
if (moveDir != 0 && !game.isPaused())
{
if ((moveDir == -1 && leftHeld == false) || (moveDir == +1 && rightHeld == false))
{
game.move(moveDir);
moveTimerMs = DAS;
}
else
{
moveTimerMs -= frameMs;
if (moveTimerMs <= 0)
{
game.move(moveDir);
moveTimerMs += ARR;
}
}
}
else
moveTimerMs = 0;
leftHeld = left;
rightHeld = right;
if (down && !game.isPaused())
game.softDropBoost(frameMs);
if (state == AppState::Playing)
{
if (!game.isPaused()) {
game.tickGravity(frameMs);
game.addElapsed(frameMs);
// Update line effect and clear lines when animation completes
if (lineEffect.isActive()) {
if (lineEffect.update(frameMs / 1000.0f)) {
// Effect is complete, now actually clear the lines
game.clearCompletedLines();
}
}
}
if (game.isGameOver())
{
scores.submit(game.score(), game.lines(), game.level(), game.elapsed());
state = AppState::GameOver;
stateMgr.setState(state);
}
}
else if (state == AppState::Loading)
{
// Initialize audio system and start background loading on first frame
if (!musicLoaded && currentTrackLoading == 0) {
Audio::instance().init();
// Count actual music files first
totalTracks = 0;
for (int i = 1; i <= 100; ++i) { // Check up to 100 files
char buf[64];
std::snprintf(buf, sizeof(buf), "assets/music/music%03d.mp3", i);
// Check if file exists
SDL_IOStream* file = SDL_IOFromFile(buf, "rb");
if (file) {
SDL_CloseIO(file);
totalTracks++;
} else {
break; // No more consecutive files
}
}
// Add all found tracks to the background loading queue
for (int i = 1; i <= totalTracks; ++i) {
char buf[64];
std::snprintf(buf, sizeof(buf), "assets/music/music%03d.mp3", i);
Audio::instance().addTrackAsync(buf);
}
// Start background loading thread
Audio::instance().startBackgroundLoading();
currentTrackLoading = 1; // Mark as started
}
// Update progress based on background loading
if (currentTrackLoading > 0 && !musicLoaded) {
currentTrackLoading = Audio::instance().getLoadedTrackCount();
if (Audio::instance().isLoadingComplete()) {
Audio::instance().shuffle(); // Shuffle once all tracks are loaded
musicLoaded = true;
}
}
// Calculate comprehensive loading progress
// Phase 1: Initial assets (textures, fonts) - 20%
double assetProgress = 0.2; // Assets are loaded at startup
// Phase 2: Music loading - 70%
double musicProgress = 0.0;
if (totalTracks > 0) {
musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7);
}
// Phase 3: Final initialization - 10%
double timeProgress = std::min(0.1, (now - loadStart) / 500.0); // Faster final phase
loadingProgress = assetProgress + musicProgress + timeProgress;
// Ensure we never exceed 100% and reach exactly 100% when everything is loaded
loadingProgress = std::min(1.0, loadingProgress);
if (musicLoaded && timeProgress >= 0.1) {
loadingProgress = 1.0;
}
if (loadingProgress >= 1.0 && musicLoaded) {
state = AppState::Menu;
stateMgr.setState(state);
}
}
if (state == AppState::Menu || state == AppState::Playing)
{
if (!musicStarted && musicLoaded)
{
// Music tracks are already loaded during loading screen, just start playback
Audio::instance().start();
musicStarted = true;
}
}
// Update starfields based on current state
if (state == AppState::Loading) {
starfield3D.update(float(frameMs / 1000.0f));
starfield3D.resize(logicalVP.w, logicalVP.h); // Update for window resize
} else {
starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h);
}
// Advance level background fade if a next texture is queued
if (nextLevelBackgroundTex) {
levelFadeElapsed += float(frameMs);
levelFadeAlpha = std::min(1.0f, levelFadeElapsed / LEVEL_FADE_DURATION);
}
// Update intro animations
if (state == AppState::Menu) {
logoAnimCounter += frameMs * 0.0008; // Animation speed
updateFireworks(frameMs);
}
// --- Per-state update hooks (allow states to manage logic incrementally)
switch (stateMgr.getState()) {
case AppState::Loading:
loadingState->update(frameMs);
break;
case AppState::Menu:
menuState->update(frameMs);
break;
case AppState::Playing:
playingState->update(frameMs);
break;
default:
break;
}
// --- Render ---
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderDrawColor(renderer, 12, 12, 16, 255);
SDL_RenderClear(renderer);
// Draw level-based background for gameplay, starfield for other states
if (state == AppState::Playing) {
// Use level-based background for gameplay with caching
int currentLevel = game.level();
int bgLevel = (currentLevel > 32) ? 32 : currentLevel; // Cap at level 32
// Only load new background if level changed
if (cachedLevel != bgLevel) {
// Load new level background into nextLevelBackgroundTex
if (nextLevelBackgroundTex) { SDL_DestroyTexture(nextLevelBackgroundTex); nextLevelBackgroundTex = nullptr; }
char bgPath[256];
snprintf(bgPath, sizeof(bgPath), "assets/images/tetris_main_back_level%d.bmp", bgLevel);
SDL_Surface* levelBgSurface = SDL_LoadBMP(bgPath);
if (levelBgSurface) {
nextLevelBackgroundTex = SDL_CreateTextureFromSurface(renderer, levelBgSurface);
SDL_DestroySurface(levelBgSurface);
// start fade transition
levelFadeAlpha = 0.0f;
levelFadeElapsed = 0.0f;
cachedLevel = bgLevel;
} else {
// don't change textures if file missing
cachedLevel = -1;
}
}
// Draw blended backgrounds if needed
if (levelBackgroundTex || nextLevelBackgroundTex) {
SDL_FRect fullRect = { 0, 0, (float)logicalVP.w, (float)logicalVP.h };
// if fade in progress
if (nextLevelBackgroundTex && levelFadeAlpha < 1.0f && levelBackgroundTex) {
// draw current with inverse alpha
SDL_SetTextureAlphaMod(levelBackgroundTex, Uint8((1.0f - levelFadeAlpha) * 255));
SDL_RenderTexture(renderer, levelBackgroundTex, nullptr, &fullRect);
SDL_SetTextureAlphaMod(nextLevelBackgroundTex, Uint8(levelFadeAlpha * 255));
SDL_RenderTexture(renderer, nextLevelBackgroundTex, nullptr, &fullRect);
// reset mods
SDL_SetTextureAlphaMod(levelBackgroundTex, 255);
SDL_SetTextureAlphaMod(nextLevelBackgroundTex, 255);
}
else if (nextLevelBackgroundTex && (!levelBackgroundTex || levelFadeAlpha >= 1.0f)) {
// finalise swap
if (levelBackgroundTex) { SDL_DestroyTexture(levelBackgroundTex); }
levelBackgroundTex = nextLevelBackgroundTex;
nextLevelBackgroundTex = nullptr;
levelFadeAlpha = 0.0f;
SDL_RenderTexture(renderer, levelBackgroundTex, nullptr, &fullRect);
}
else if (levelBackgroundTex) {
SDL_RenderTexture(renderer, levelBackgroundTex, nullptr, &fullRect);
}
}
} else if (state == AppState::Loading) {
// Use 3D starfield for loading screen (full screen)
starfield3D.draw(renderer);
} else if (state == AppState::Menu) {
// Use static background for menu, stretched to window; no starfield on sides
if (backgroundTex) {
SDL_FRect fullRect = { 0, 0, (float)logicalVP.w, (float)logicalVP.h };
SDL_RenderTexture(renderer, backgroundTex, nullptr, &fullRect);
}
} else {
// Use regular starfield for other states (not gameplay)
starfield.draw(renderer);
}
SDL_SetRenderViewport(renderer, &logicalVP);
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
switch (state)
{
case AppState::Loading:
{
// Calculate actual content area (centered within the window)
float contentScale = logicalScale;
float contentW = LOGICAL_W * contentScale;
float contentH = LOGICAL_H * contentScale;
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
auto drawRect = [&](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); };
// Calculate dimensions for perfect centering (like JavaScript version)
const bool isLimitedHeight = LOGICAL_H < 450;
const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0;
const float loadingTextHeight = 20; // Height of "LOADING" text (match JS)
const float barHeight = 20; // Loading bar height (match JS)
const float barPaddingVertical = isLimitedHeight ? 15 : 35;
const float percentTextHeight = 24; // Height of percentage text
const float spacingBetweenElements = isLimitedHeight ? 5 : 15;
// Total content height
const float totalContentHeight = logoHeight +
(logoHeight > 0 ? spacingBetweenElements : 0) +
loadingTextHeight +
barPaddingVertical +
barHeight +
spacingBetweenElements +
percentTextHeight;
// Start Y position for perfect vertical centering
float currentY = (LOGICAL_H - totalContentHeight) / 2.0f;
// Draw logo (centered, static like JavaScript version)
if (logoTex)
{
// Use the same original large logo dimensions as JS (we used a half-size BMP previously)
const int lw = 872, lh = 273;
// Cap logo width similar to JS UI.MAX_LOGO_WIDTH (600) and available screen space
const float maxLogoWidth = std::min(LOGICAL_W * 0.9f, 600.0f);
const float availableHeight = isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f;
const float availableWidth = maxLogoWidth;
const float scaleFactorWidth = availableWidth / static_cast<float>(lw);
const float scaleFactorHeight = availableHeight / static_cast<float>(lh);
const float scaleFactor = std::min(scaleFactorWidth, scaleFactorHeight);
const float displayWidth = lw * scaleFactor;
const float displayHeight = lh * scaleFactor;
const float logoX = (LOGICAL_W - displayWidth) / 2.0f;
SDL_FRect dst{logoX + contentOffsetX, currentY + contentOffsetY, displayWidth, displayHeight};
SDL_RenderTexture(renderer, logoTex, nullptr, &dst);
currentY += displayHeight + spacingBetweenElements;
}
// Draw "LOADING" text (centered, using pixel font)
const char* loadingText = "LOADING";
float textWidth = strlen(loadingText) * 12.0f; // Approximate width for pixel font
float textX = (LOGICAL_W - textWidth) / 2.0f;
pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255});
currentY += loadingTextHeight + barPaddingVertical;
// Draw loading bar (like JavaScript version)
const int barW = 400, barH = 20;
const int bx = (LOGICAL_W - barW) / 2;
// Bar border (dark gray) - using drawRect which adds content offset
drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255});
// Bar background (darker gray)
drawRect(bx, currentY, barW, barH, {34, 34, 34, 255});
// Progress bar (gold color)
drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255});
currentY += barH + spacingBetweenElements;
// Draw percentage text (centered, using pixel font)
int percentage = int(loadingProgress * 100);
char percentText[16];
std::snprintf(percentText, sizeof(percentText), "%d%%", percentage);
float percentWidth = strlen(percentText) * 12.0f; // Approximate width for pixel font
float percentX = (LOGICAL_W - percentWidth) / 2.0f;
pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255});
}
break;
case AppState::Menu:
// Delegate full menu rendering to MenuState object now
menuState->render(renderer, logicalScale, logicalVP);
break;
case AppState::LevelSelect:
font.draw(renderer, LOGICAL_W * 0.5f - 120, 80, "SELECT LEVEL", 2.5f, SDL_Color{255, 220, 0, 255});
{
char buf[64];
std::snprintf(buf, sizeof(buf), "LEVEL: %d", startLevelSelection);
font.draw(renderer, LOGICAL_W * 0.5f - 80, 180, buf, 2.0f, SDL_Color{200, 240, 255, 255});
}
font.draw(renderer, LOGICAL_W * 0.5f - 180, 260, "ARROWS CHANGE ENTER=OK ESC=BACK", 1.2f, SDL_Color{200, 200, 220, 255});
break;
case AppState::Playing:
{
// Calculate actual content area (centered within the window)
float contentScale = logicalScale;
float contentW = LOGICAL_W * contentScale;
float contentH = LOGICAL_H * contentScale;
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
// Draw the game with layout matching the JavaScript version
auto drawRect = [&](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
// Calculate available space considering UI panels and margins
const float MIN_MARGIN = 40.0f; // Minimum margin from edges
const float TOP_MARGIN = 60.0f; // Extra top margin for better spacing
const float PANEL_WIDTH = 180.0f; // Width of side panels
const float PANEL_SPACING = 30.0f; // Space between grid and panels
const float NEXT_PIECE_HEIGHT = 120.0f; // Space reserved for next piece preview (increased)
const float BOTTOM_MARGIN = 60.0f; // Space for controls text at bottom
// Available width = Total width - margins - left panel - right panel - spacing
const float availableWidth = LOGICAL_W - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
const float availableHeight = LOGICAL_H - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;
// Calculate block size based on available space (maintain 10:20 aspect ratio)
const float maxBlockSizeW = availableWidth / Game::COLS;
const float maxBlockSizeH = availableHeight / Game::ROWS;
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
// Ensure minimum and maximum block sizes
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 vertical position with proper top margin
const float totalContentHeight = NEXT_PIECE_HEIGHT + GRID_H;
const float availableVerticalSpace = LOGICAL_H - TOP_MARGIN - BOTTOM_MARGIN;
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
// Perfect horizontal centering - center the entire layout (grid + panels) in the window
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
const float layoutStartX = (LOGICAL_W - totalLayoutWidth) * 0.5f;
// Calculate panel and grid positions from the centered layout
const float statsX = layoutStartX + contentOffsetX;
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
const float scoreX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + contentOffsetX;
// Position grid with proper top spacing
const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY;
// Panel dimensions and positions
const float statsY = gridY;
const float statsW = PANEL_WIDTH;
const float statsH = GRID_H;
const float scoreY = gridY;
const float scoreW = PANEL_WIDTH;
// Next piece preview (above grid, centered)
const float nextW = finalBlockSize * 4 + 20;
const float nextH = finalBlockSize * 2 + 20;
const float nextX = gridX + (GRID_W - nextW) * 0.5f;
const float nextY = contentStartY + contentOffsetY;
// Handle line clearing effects (now that we have grid coordinates)
if (game.hasCompletedLines() && !lineEffect.isActive()) {
auto completedLines = game.getCompletedLines();
lineEffect.startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
}
// Draw panels with borders (like JS version)
// Game grid border
drawRect(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); // Outer border
drawRect(gridX - 1 - contentOffsetX, gridY - 1 - contentOffsetY, GRID_W + 2, GRID_H + 2, {60, 80, 160, 255}); // Inner border
drawRect(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); // Background
// Left panel background (BLOCKS panel) - translucent, slightly shorter height
{
SDL_SetRenderDrawColor(renderer, 10, 15, 25, 160);
SDL_FRect lbg{statsX - 16, gridY - 10, statsW + 32, GRID_H + 20};
SDL_RenderFillRect(renderer, &lbg);
}
// Right panel background (SCORE/LINES/LEVEL etc) - translucent
{
SDL_SetRenderDrawColor(renderer, 10, 15, 25, 160);
SDL_FRect rbg{scoreX - 16, gridY - 16, scoreW + 32, GRID_H + 32};
SDL_RenderFillRect(renderer, &rbg);
}
// Draw grid lines (subtle lines to show cell boundaries)
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); // Slightly lighter than background
// Vertical grid lines
for (int x = 1; x < Game::COLS; ++x) {
float lineX = gridX + x * finalBlockSize; // Remove duplicate contentOffsetX
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
}
// Horizontal grid lines
for (int y = 1; y < Game::ROWS; ++y) {
float lineY = gridY + y * finalBlockSize; // Remove duplicate contentOffsetY
SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY);
}
// Block statistics panel
drawRect(statsX - 3 - contentOffsetX, statsY - 3 - contentOffsetY, statsW + 6, statsH + 6, {100, 120, 200, 255});
drawRect(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255});
// Next piece preview panel
drawRect(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255});
drawRect(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255});
// Draw the game board
const auto &board = game.boardRef();
for (int y = 0; y < Game::ROWS; ++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;
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1);
}
}
}
// Draw ghost piece (where current piece will land)
if (!game.isPaused()) {
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 (!game.isPaused()) {
drawPiece(renderer, blocksTex, game.current(), gridX, gridY, finalBlockSize, false);
}
// Draw line clearing effects
if (lineEffect.isActive()) {
lineEffect.render(renderer, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
}
// Draw next piece preview
pixelFont.draw(renderer, nextX + 10, nextY - 20, "NEXT", 1.0f, {255, 220, 0, 255});
if (game.next().type < PIECE_COUNT) {
drawSmallPiece(renderer, blocksTex, game.next().type, nextX + 10, nextY + 10, finalBlockSize * 0.6f);
}
// Draw block statistics (left panel)
pixelFont.draw(renderer, statsX + 10, statsY + 10, "BLOCKS", 1.0f, {255, 220, 0, 255});
const auto& blockCounts = game.getBlockCounts();
int totalBlocks = 0; for (int i = 0; i < PIECE_COUNT; ++i) totalBlocks += blockCounts[i];
const char* pieceNames[] = {"I", "O", "T", "S", "Z", "J", "L"};
// Dynamic vertical cursor so bars sit below blocks cleanly
float yCursor = statsY + 52;
for (int i = 0; i < PIECE_COUNT; ++i) {
// Baseline for this entry
float py = yCursor;
// Draw small piece icon (top of entry)
float previewSize = finalBlockSize * 0.55f;
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(i), statsX + 18, py, previewSize);
// Compute preview height in tiles (rotation 0)
int maxCy = -1;
{
Game::Piece prev; prev.type = static_cast<PieceType>(i); prev.rot = 0; prev.x = 0; prev.y = 0;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (Game::cellFilled(prev, cx, cy)) maxCy = std::max(maxCy, cy);
}
}
}
int tilesHigh = (maxCy >= 0 ? maxCy + 1 : 1);
float previewHeight = tilesHigh * previewSize;
// Count on the right, near the top (aligned with blocks)
int count = blockCounts[i];
char countStr[16];
snprintf(countStr, sizeof(countStr), "%d", count);
pixelFont.draw(renderer, statsX + statsW - 20, py + 6, countStr, 1.1f, {240, 240, 245, 255});
// Percentage and bar BELOW the blocks
int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(count) / double(totalBlocks))) : 0;
char percStr[16];
snprintf(percStr, sizeof(percStr), "%d%%", perc);
float barX = statsX + 12;
float barY = py + previewHeight + 18.0f;
float barW = statsW - 24;
float barH = 6;
// Percent text just above the bar (left)
pixelFont.draw(renderer, barX, barY - 16, percStr, 0.8f, {230, 230, 235, 255});
// Track
SDL_SetRenderDrawColor(renderer, 170, 170, 175, 200);
SDL_FRect track{barX, barY, barW, barH};
SDL_RenderFillRect(renderer, &track);
// Fill (piece color)
SDL_Color pc = COLORS[i + 1];
SDL_SetRenderDrawColor(renderer, pc.r, pc.g, pc.b, 230);
float fillW = barW * (perc / 100.0f);
if (fillW < 0) fillW = 0; if (fillW > barW) fillW = barW;
SDL_FRect fill{barX, barY, fillW, barH};
SDL_RenderFillRect(renderer, &fill);
// Advance cursor: bar bottom + spacing
yCursor = barY + barH + 18.0f;
}
// Draw score panel (right side), centered vertically in grid
// Compute content vertical centering based on known offsets
const float contentTopOffset = 0.0f;
const float contentBottomOffset = 290.0f; // last line (time value)
const float contentPad = 36.0f;
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
pixelFont.draw(renderer, scoreX, baseY + 0, "SCORE", 1.0f, {255, 220, 0, 255});
char scoreStr[32];
snprintf(scoreStr, sizeof(scoreStr), "%d", game.score());
pixelFont.draw(renderer, scoreX, baseY + 25, scoreStr, 0.9f, {255, 255, 255, 255});
pixelFont.draw(renderer, scoreX, baseY + 70, "LINES", 1.0f, {255, 220, 0, 255});
char linesStr[16];
snprintf(linesStr, sizeof(linesStr), "%03d", game.lines());
pixelFont.draw(renderer, scoreX, baseY + 95, linesStr, 0.9f, {255, 255, 255, 255});
pixelFont.draw(renderer, scoreX, baseY + 140, "LEVEL", 1.0f, {255, 220, 0, 255});
char levelStr[16];
snprintf(levelStr, sizeof(levelStr), "%02d", game.level());
pixelFont.draw(renderer, scoreX, baseY + 165, levelStr, 0.9f, {255, 255, 255, 255});
// Next level progress
// JS rules: first threshold = (startLevel+1)*10; afterwards every +10
int startLv = game.startLevelBase(); // 0-based
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);
pixelFont.draw(renderer, scoreX, baseY + 200, "NEXT LVL", 1.0f, {255, 220, 0, 255});
char nextStr[32];
snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
pixelFont.draw(renderer, scoreX, baseY + 225, nextStr, 0.9f, {80, 255, 120, 255});
// Time
pixelFont.draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255});
int totalSecs = static_cast<int>(game.elapsed());
int mins = totalSecs / 60;
int secs = totalSecs % 60;
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
pixelFont.draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255});
// --- Gravity HUD: show current gravity in ms and equivalent fps (top-right) ---
{
char gms[64];
double gms_val = game.getGravityMs();
double gfps = gms_val > 0.0 ? (1000.0 / gms_val) : 0.0;
snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps);
pixelFont.draw(renderer, LOGICAL_W - 260, 10, gms, 0.9f, {200, 200, 220, 255});
}
// Hold piece (if implemented)
if (game.held().type < PIECE_COUNT) {
pixelFont.draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255});
drawSmallPiece(renderer, blocksTex, game.held().type, statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f);
}
// Pause overlay: don't draw pause UI when the exit-confirm popup is showing
if (game.isPaused() && !showExitConfirmPopup) {
// Semi-transparent overlay
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180);
SDL_FRect pauseOverlay{0, 0, LOGICAL_W, LOGICAL_H};
SDL_RenderFillRect(renderer, &pauseOverlay);
// Pause text
pixelFont.draw(renderer, LOGICAL_W * 0.5f - 80, LOGICAL_H * 0.5f - 20, "PAUSED", 2.0f, {255, 255, 255, 255});
pixelFont.draw(renderer, LOGICAL_W * 0.5f - 120, LOGICAL_H * 0.5f + 30, "Press P to resume", 0.8f, {200, 200, 220, 255});
}
// Exit confirmation popup (modal)
if (showExitConfirmPopup) {
// Compute content offsets for consistent placement across window sizes
float contentW = LOGICAL_W * logicalScale;
float contentH = LOGICAL_H * logicalScale;
float contentOffsetX = (winW - contentW) * 0.5f / logicalScale;
float contentOffsetY = (winH - contentH) * 0.5f / logicalScale;
float popupW = 420, popupH = 180;
float popupX = (LOGICAL_W - popupW) / 2;
float popupY = (LOGICAL_H - popupH) / 2;
// Dim entire window (use window coordinates so it always covers 100% of the target)
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 200);
SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &fullWin);
// Restore logical viewport for drawing content-local popup
SDL_SetRenderViewport(renderer, &logicalVP);
// Draw popup box (drawRect will apply contentOffset internally)
drawRect(popupX - 4, popupY - 4, popupW + 8, popupH + 8, {60, 70, 90, 255});
drawRect(popupX, popupY, popupW, popupH, {20, 22, 28, 240});
// Center title and body text inside popup (use pixelFont for retro P2 font)
const std::string title = "Exit game?";
const std::string line1 = "Are you sure you want to";
const std::string line2 = "leave the current game?";
int wTitle=0,hTitle=0; pixelFont.measure( title, 1.6f, wTitle, hTitle);
int wL1=0,hL1=0; pixelFont.measure( line1, 0.9f, wL1, hL1);
int wL2=0,hL2=0; pixelFont.measure( line2, 0.9f, wL2, hL2);
float titleX = popupX + (popupW - (float)wTitle) * 0.5f;
float l1X = popupX + (popupW - (float)wL1) * 0.5f;
float l2X = popupX + (popupW - (float)wL2) * 0.5f;
pixelFont.draw(renderer, titleX + contentOffsetX, popupY + contentOffsetY + 20, title, 1.6f, {255, 220, 0, 255});
pixelFont.draw(renderer, l1X + contentOffsetX, popupY + contentOffsetY + 60, line1, 0.9f, SDL_Color{220,220,230,255});
pixelFont.draw(renderer, l2X + contentOffsetX, popupY + contentOffsetY + 84, line2, 0.9f, SDL_Color{220,220,230,255});
// Buttons (center labels inside buttons) - use pixelFont for labels
float btnW = 140, btnH = 46;
float yesX = popupX + popupW * 0.25f - btnW/2.0f;
float noX = popupX + popupW * 0.75f - btnW/2.0f;
float btnY = popupY + popupH - 60;
drawRect(yesX - 2, btnY - 2, btnW + 4, btnH + 4, {100, 120, 140, 255});
drawRect(yesX, btnY, btnW, btnH, {200, 60, 60, 255});
const std::string yes = "YES";
int wy=0,hy=0; pixelFont.measure( yes, 1.0f, wy, hy);
pixelFont.draw(renderer, yesX + (btnW - (float)wy) * 0.5f + contentOffsetX, btnY + (btnH - (float)hy) * 0.5f + contentOffsetY, yes, 1.0f, {255,255,255,255});
drawRect(noX - 2, btnY - 2, btnW + 4, btnH + 4, {100, 120, 140, 255});
drawRect(noX, btnY, btnW, btnH, {80, 140, 80, 255});
const std::string no = "NO";
int wn=0,hn=0; pixelFont.measure( no, 1.0f, wn, hn);
pixelFont.draw(renderer, noX + (btnW - (float)wn) * 0.5f + contentOffsetX, btnY + (btnH - (float)hn) * 0.5f + contentOffsetY, no, 1.0f, {255,255,255,255});
}
// Controls hint at bottom
font.draw(renderer, 20, LOGICAL_H - 30, "ARROWS=Move Z/X=Rotate C=Hold SPACE=Drop P=Pause ESC=Menu", 1.0f, {150, 150, 170, 255});
}
break;
case AppState::GameOver:
font.draw(renderer, LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, SDL_Color{255, 80, 60, 255});
{
char buf[128];
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", game.score(), game.lines(), game.level());
font.draw(renderer, LOGICAL_W * 0.5f - 120, 220, buf, 1.2f, SDL_Color{220, 220, 230, 255});
}
font.draw(renderer, LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, SDL_Color{200, 200, 220, 255});
break;
}
SDL_RenderPresent(renderer);
SDL_SetRenderScale(renderer, 1.f, 1.f);
}
if (logoTex)
SDL_DestroyTexture(logoTex);
if (backgroundTex)
SDL_DestroyTexture(backgroundTex);
if (levelBackgroundTex)
SDL_DestroyTexture(levelBackgroundTex);
if (blocksTex)
SDL_DestroyTexture(blocksTex);
lineEffect.shutdown();
Audio::instance().shutdown();
SoundEffectManager::instance().shutdown();
font.shutdown();
TTF_Quit();
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}