Files
spacetris/src/main.cpp
2025-12-17 18:55:55 +01:00

2060 lines
92 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_image/SDL_image.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 <filesystem>
#include <thread>
#include <atomic>
#include <mutex>
#include <sstream>
#include "audio/Audio.h"
#include "audio/SoundEffect.h"
#include "gameplay/core/Game.h"
#include "persistence/Scores.h"
#include "graphics/effects/Starfield.h"
#include "graphics/effects/Starfield3D.h"
#include "graphics/effects/SpaceWarp.h"
#include "graphics/ui/Font.h"
#include "graphics/ui/HelpOverlay.h"
#include "gameplay/effects/LineEffect.h"
#include "states/State.h"
#include "states/LoadingState.h"
#include "states/MenuState.h"
#include "states/OptionsState.h"
#include "states/LevelSelectorState.h"
#include "states/PlayingState.h"
#include "audio/MenuWrappers.h"
#include "app/AssetLoader.h"
#include "states/LoadingManager.h"
#include "utils/ImagePathResolver.h"
#include "graphics/renderers/GameRenderer.h"
#include "core/Config.h"
#include "core/Settings.h"
#include "ui/MenuLayout.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;
#include "ui/UIConstants.h"
// 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
}};
// Global collector for asset loading errors shown on the loading screen
static std::vector<std::string> g_assetLoadErrors;
static std::mutex g_assetLoadErrorsMutex;
// Loading counters for progress UI and debug overlay
static std::atomic<int> g_totalLoadingTasks{0};
static std::atomic<int> g_loadedTasks{0};
static std::string g_currentLoadingFile;
static std::mutex g_currentLoadingMutex;
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);
}
static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) {
if (!renderer) {
return nullptr;
}
const std::string resolvedPath = AssetPath::resolveImagePath(path);
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile = resolvedPath.empty() ? path : resolvedPath;
}
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
if (!surface) {
// Record the error for display on the loading screen
{
std::lock_guard<std::mutex> lk(g_assetLoadErrorsMutex);
std::ostringstream ss;
ss << "Image load failed: " << path << " (" << resolvedPath << "): " << SDL_GetError();
g_assetLoadErrors.emplace_back(ss.str());
}
g_loadedTasks.fetch_add(1);
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile.clear();
}
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError());
return nullptr;
}
if (outW) { *outW = surface->w; }
if (outH) { *outH = surface->h; }
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
SDL_DestroySurface(surface);
if (!texture) {
{
std::lock_guard<std::mutex> lk(g_assetLoadErrorsMutex);
std::ostringstream ss;
ss << "Texture create failed: " << resolvedPath << ": " << SDL_GetError();
g_assetLoadErrors.emplace_back(ss.str());
}
g_loadedTasks.fetch_add(1);
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile.clear();
}
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError());
return nullptr;
}
// Mark this task as completed
g_loadedTasks.fetch_add(1);
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile.clear();
}
if (resolvedPath != path) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str());
}
return texture;
}
enum class LevelBackgroundPhase { Idle, ZoomOut, ZoomIn };
struct LevelBackgroundFader {
SDL_Texture* currentTex = nullptr;
SDL_Texture* nextTex = nullptr;
int currentLevel = -1;
int queuedLevel = -1;
float phaseElapsedMs = 0.0f;
float phaseDurationMs = 0.0f;
float fadeDurationMs = Config::Gameplay::LEVEL_FADE_DURATION;
LevelBackgroundPhase phase = LevelBackgroundPhase::Idle;
};
static float getPhaseDurationMs(const LevelBackgroundFader& fader, LevelBackgroundPhase phase) {
const float total = std::max(1200.0f, fader.fadeDurationMs);
switch (phase) {
case LevelBackgroundPhase::ZoomOut: return total * 0.45f;
case LevelBackgroundPhase::ZoomIn: return total * 0.45f;
case LevelBackgroundPhase::Idle:
default: return 0.0f;
}
}
static void setPhase(LevelBackgroundFader& fader, LevelBackgroundPhase nextPhase) {
fader.phase = nextPhase;
fader.phaseDurationMs = getPhaseDurationMs(fader, nextPhase);
fader.phaseElapsedMs = 0.0f;
}
static void destroyTexture(SDL_Texture*& tex) {
if (tex) {
SDL_DestroyTexture(tex);
tex = nullptr;
}
}
static bool queueLevelBackground(LevelBackgroundFader& fader, SDL_Renderer* renderer, int level) {
if (!renderer) {
return false;
}
level = std::clamp(level, 0, 32);
if (fader.currentLevel == level || fader.queuedLevel == level) {
return true;
}
char bgPath[256];
std::snprintf(bgPath, sizeof(bgPath), "assets/images/levels/level%d.jpg", level);
SDL_Texture* newTexture = loadTextureFromImage(renderer, bgPath);
if (!newTexture) {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to queue background for level %d: %s", level, bgPath);
return false;
}
destroyTexture(fader.nextTex);
fader.nextTex = newTexture;
fader.queuedLevel = level;
if (!fader.currentTex) {
// First background load happens instantly.
fader.currentTex = fader.nextTex;
fader.currentLevel = fader.queuedLevel;
fader.nextTex = nullptr;
fader.queuedLevel = -1;
fader.phase = LevelBackgroundPhase::Idle;
fader.phaseElapsedMs = 0.0f;
fader.phaseDurationMs = 0.0f;
} else if (fader.phase == LevelBackgroundPhase::Idle) {
// Kick off fancy transition.
setPhase(fader, LevelBackgroundPhase::ZoomOut);
}
return true;
}
static void updateLevelBackgroundFade(LevelBackgroundFader& fader, float frameMs) {
if (fader.phase == LevelBackgroundPhase::Idle) {
return;
}
// Guard against missing textures
if (!fader.currentTex && !fader.nextTex) {
fader.phase = LevelBackgroundPhase::Idle;
return;
}
fader.phaseElapsedMs += frameMs;
if (fader.phaseElapsedMs < std::max(1.0f, fader.phaseDurationMs)) {
return;
}
switch (fader.phase) {
case LevelBackgroundPhase::ZoomOut:
// After zoom-out, swap textures then start zoom-in.
if (fader.nextTex) {
destroyTexture(fader.currentTex);
fader.currentTex = fader.nextTex;
fader.currentLevel = fader.queuedLevel;
fader.nextTex = nullptr;
fader.queuedLevel = -1;
}
setPhase(fader, LevelBackgroundPhase::ZoomIn);
break;
case LevelBackgroundPhase::ZoomIn:
fader.phase = LevelBackgroundPhase::Idle;
fader.phaseElapsedMs = 0.0f;
fader.phaseDurationMs = 0.0f;
break;
case LevelBackgroundPhase::Idle:
default:
fader.phase = LevelBackgroundPhase::Idle;
break;
}
}
static void renderScaledBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float scale, Uint8 alpha = 255) {
if (!renderer || !tex) {
return;
}
scale = std::max(0.5f, scale);
SDL_FRect dest{
(winW - winW * scale) * 0.5f,
(winH - winH * scale) * 0.5f,
winW * scale,
winH * scale
};
SDL_SetTextureAlphaMod(tex, alpha);
SDL_RenderTexture(renderer, tex, nullptr, &dest);
SDL_SetTextureAlphaMod(tex, 255);
}
static void renderDynamicBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float baseScale, float motionClockMs, float alphaMul = 1.0f) {
if (!renderer || !tex) {
return;
}
const float seconds = motionClockMs * 0.001f;
const float wobble = std::max(0.4f, baseScale + std::sin(seconds * 0.07f) * 0.02f + std::sin(seconds * 0.23f) * 0.01f);
const float rotation = std::sin(seconds * 0.035f) * 1.25f;
const float panX = std::sin(seconds * 0.11f) * winW * 0.02f;
const float panY = std::cos(seconds * 0.09f) * winH * 0.015f;
SDL_FRect dest{
(winW - winW * wobble) * 0.5f + panX,
(winH - winH * wobble) * 0.5f + panY,
winW * wobble,
winH * wobble
};
SDL_FPoint center{dest.w * 0.5f, dest.h * 0.5f};
Uint8 alpha = static_cast<Uint8>(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f);
SDL_SetTextureAlphaMod(tex, alpha);
SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, &center, SDL_FLIP_NONE);
SDL_SetTextureAlphaMod(tex, 255);
}
static void drawOverlay(SDL_Renderer* renderer, const SDL_FRect& rect, SDL_Color color, Uint8 alpha) {
if (!renderer || alpha == 0) {
return;
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, alpha);
SDL_RenderFillRect(renderer, &rect);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}
static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Renderer* renderer, int winW, int winH, float motionClockMs) {
if (!renderer) {
return;
}
SDL_FRect fullRect{0.f, 0.f, static_cast<float>(winW), static_cast<float>(winH)};
const float duration = std::max(1.0f, fader.phaseDurationMs);
const float progress = (fader.phase == LevelBackgroundPhase::Idle) ? 0.0f : std::clamp(fader.phaseElapsedMs / duration, 0.0f, 1.0f);
const float seconds = motionClockMs * 0.001f;
switch (fader.phase) {
case LevelBackgroundPhase::ZoomOut: {
const float scale = 1.0f + progress * 0.15f;
if (fader.currentTex) {
renderDynamicBackground(renderer, fader.currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f));
drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, Uint8(progress * 200.0f));
}
break;
}
case LevelBackgroundPhase::ZoomIn: {
const float scale = 1.10f - progress * 0.10f;
const Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f);
if (fader.currentTex) {
renderDynamicBackground(renderer, fader.currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f);
}
break;
}
case LevelBackgroundPhase::Idle:
default:
if (fader.currentTex) {
renderDynamicBackground(renderer, fader.currentTex, winW, winH, 1.02f, motionClockMs, 1.0f);
float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f));
drawOverlay(renderer, fullRect, SDL_Color{5, 12, 28, 255}, Uint8(pulse * 90.0f));
} else if (fader.nextTex) {
renderDynamicBackground(renderer, fader.nextTex, winW, winH, 1.02f, motionClockMs, 1.0f);
} else {
drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, 255);
}
break;
}
}
static void resetLevelBackgrounds(LevelBackgroundFader& fader) {
destroyTexture(fader.currentTex);
destroyTexture(fader.nextTex);
fader.currentLevel = -1;
fader.queuedLevel = -1;
fader.phaseElapsedMs = 0.0f;
fader.phaseDurationMs = 0.0f;
fader.phase = LevelBackgroundPhase::Idle;
}
// Hover state for level popup ( -1 = none, 0..19 = hovered level )
// Now managed by LevelSelectorState
// ...existing code...
// Legacy rendering functions removed (moved to UIRenderer / GameRenderer)
// -----------------------------------------------------------------------------
// Starfield effect for background
// -----------------------------------------------------------------------------
// Starfield now managed by Starfield class
// State manager integration (scaffolded in StateManager.h)
#include "core/state/StateManager.h"
// -----------------------------------------------------------------------------
// Intro/Menu state variables
// -----------------------------------------------------------------------------
#include "app/BackgroundManager.h"
#include "app/Fireworks.h"
static double logoAnimCounter = 0.0;
static bool showSettingsPopup = false;
static bool showHelpOverlay = false;
static bool showExitConfirmPopup = false;
static int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO
static bool musicEnabled = true;
static int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
static bool isNewHighScore = false;
static std::string playerName = "";
static bool helpOverlayPausedGame = false;
// Fireworks implementation moved to app/Fireworks.{h,cpp}
int main(int, char **)
{
// Initialize random seed for procedural effects
srand(static_cast<unsigned int>(SDL_GetTicks()));
// Load settings
Settings::instance().load();
// Sync static variables with settings
musicEnabled = Settings::instance().isMusicEnabled();
playerName = Settings::instance().getPlayerName();
if (playerName.empty()) playerName = "Player";
// Apply sound settings to manager
SoundEffectManager::instance().setEnabled(Settings::instance().isSoundEnabled());
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_WindowFlags windowFlags = SDL_WINDOW_RESIZABLE;
if (Settings::instance().isFullscreen()) {
windowFlags |= SDL_WINDOW_FULLSCREEN;
}
SDL_Window *window = SDL_CreateWindow("Tetris (SDL3)", LOGICAL_W, LOGICAL_H, windowFlags);
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);
if (const char* basePathRaw = SDL_GetBasePath()) {
std::filesystem::path exeDir(basePathRaw);
AssetPath::setBasePath(exeDir.string());
#if defined(__APPLE__)
// On macOS bundles launched from Finder start in /, so re-root relative paths.
std::error_code ec;
std::filesystem::current_path(exeDir, ec);
if (ec) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"Failed to set working directory to %s: %s",
exeDir.string().c_str(), ec.message().c_str());
}
#endif
} else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"SDL_GetBasePath() failed; asset lookups rely on current directory: %s",
SDL_GetError());
}
// Asset loader (creates SDL_Textures on the main thread)
AssetLoader assetLoader;
assetLoader.init(renderer);
LoadingManager loadingManager(&assetLoader);
// Font and UI asset handles (actual loading deferred until Loading state)
FontAtlas pixelFont;
FontAtlas font;
ScoreManager scores;
std::atomic<bool> scoresLoadComplete{false};
// Load scores asynchronously but keep the worker alive until shutdown to avoid lifetime issues
std::jthread scoreLoader([&scores, &scoresLoadComplete]() {
scores.load();
scoresLoadComplete.store(true, std::memory_order_release);
});
std::jthread menuTrackLoader;
Starfield starfield;
starfield.init(200, LOGICAL_W, LOGICAL_H);
Starfield3D starfield3D;
starfield3D.init(LOGICAL_W, LOGICAL_H, 200);
SpaceWarp spaceWarp;
spaceWarp.init(LOGICAL_W, LOGICAL_H, 420);
SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward;
spaceWarp.setFlightMode(warpFlightMode);
bool warpAutoPilotEnabled = true;
spaceWarp.setAutoPilotEnabled(true);
// Initialize line clearing effects
LineEffect lineEffect;
lineEffect.init(renderer);
// Asset handles (textures initialized by loader thread when Loading state starts)
SDL_Texture* logoTex = nullptr;
int logoSmallW = 0, logoSmallH = 0;
SDL_Texture* logoSmallTex = nullptr;
SDL_Texture* backgroundTex = nullptr; // No static background texture is used
int mainScreenW = 0, mainScreenH = 0;
SDL_Texture* mainScreenTex = nullptr;
// Level background manager (moved to BackgroundManager)
BackgroundManager levelBackgrounds;
// Default start level selection: 0 (declare here so it's in scope for all handlers)
int startLevelSelection = 0;
SDL_Texture* blocksTex = nullptr;
SDL_Texture* scorePanelTex = nullptr;
SDL_Texture* statisticsPanelTex = nullptr;
SDL_Texture* nextPanelTex = nullptr;
// Music loading tracking
int totalTracks = 0;
int currentTrackLoading = 0;
bool musicLoaded = false;
bool musicStarted = false;
bool musicLoadingStarted = false;
// Loader control: execute incrementally on main thread to avoid SDL threading issues
std::atomic_bool g_loadingStarted{false};
std::atomic_bool g_loadingComplete{false};
std::atomic<size_t> g_loadingStep{0};
// Loading is now handled by AssetLoader + LoadingManager.
// Old incremental lambda removed; use LoadingManager to queue texture loads and
// perform a single step per frame. Non-texture initialization (fonts, SFX)
// is performed on the first loading frame below when the loader is started.
Game game(startLevelSelection);
// Apply global gravity speed multiplier from config
game.setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
game.reset(startLevelSelection);
// Sound effects system already initialized; audio loads are handled by loader thread
// Define voice line banks for gameplay callbacks
std::vector<std::string> singleSounds = {"well_played", "smooth_clear", "great_move"};
std::vector<std::string> doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
std::vector<std::string> tripleSounds = {"impressive", "triple_strike"};
std::vector<std::string> tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"};
bool suppressLineVoiceForLevelUp = false;
auto playVoiceCue = [&](int linesCleared) {
const std::vector<std::string>* bank = nullptr;
switch (linesCleared) {
case 1: bank = &singleSounds; break;
case 2: bank = &doubleSounds; break;
case 3: bank = &tripleSounds; break;
default:
if (linesCleared >= 4) {
bank = &tetrisSounds;
}
break;
}
if (bank && !bank->empty()) {
SoundEffectManager::instance().playRandomSound(*bank, 1.0f);
}
};
// Set up sound effect callbacks
game.setSoundCallback([&, playVoiceCue](int linesCleared) {
if (linesCleared <= 0) {
return;
}
// Always play the core line-clear sound for consistency
SoundEffectManager::instance().playSound("clear_line", 1.0f);
// Layer a voiced callout based on the number of cleared lines
if (!suppressLineVoiceForLevelUp) {
playVoiceCue(linesCleared);
}
suppressLineVoiceForLevelUp = false;
});
game.setLevelUpCallback([&](int newLevel) {
SoundEffectManager::instance().playSound("new_level", 1.0f);
SoundEffectManager::instance().playSound("lets_go", 1.0f); // Existing voice line
suppressLineVoiceForLevelUp = true;
});
AppState state = AppState::Loading;
double loadingProgress = 0.0;
Uint64 loadStart = SDL_GetTicks();
bool running = true;
bool isFullscreen = Settings::instance().isFullscreen();
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_GetPerformanceCounter();
enum class MenuFadePhase { None, FadeOut, FadeIn };
MenuFadePhase menuFadePhase = MenuFadePhase::None;
double menuFadeClockMs = 0.0;
float menuFadeAlpha = 0.0f;
const double MENU_PLAY_FADE_DURATION_MS = 450.0;
AppState menuFadeTarget = AppState::Menu;
bool menuPlayCountdownArmed = false;
bool gameplayCountdownActive = false;
double gameplayCountdownElapsed = 0.0;
int gameplayCountdownIndex = 0;
const double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0;
const std::array<const char*, 4> GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" };
double gameplayBackgroundClockMs = 0.0;
// 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 = nullptr; // populated once async load finishes
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 = nullptr;
ctx.blocksTex = blocksTex;
ctx.scorePanelTex = scorePanelTex;
ctx.statisticsPanelTex = statisticsPanelTex;
ctx.nextPanelTex = nextPanelTex;
ctx.mainScreenTex = mainScreenTex;
ctx.mainScreenW = mainScreenW;
ctx.mainScreenH = mainScreenH;
ctx.musicEnabled = &musicEnabled;
ctx.startLevelSelection = &startLevelSelection;
ctx.hoveredButton = &hoveredButton;
ctx.showSettingsPopup = &showSettingsPopup;
ctx.showHelpOverlay = &showHelpOverlay;
ctx.showExitConfirmPopup = &showExitConfirmPopup;
ctx.exitPopupSelectedButton = &exitPopupSelectedButton;
ctx.gameplayCountdownActive = &gameplayCountdownActive;
ctx.menuPlayCountdownArmed = &menuPlayCountdownArmed;
ctx.playerName = &playerName;
ctx.fullscreenFlag = &isFullscreen;
ctx.applyFullscreen = [window, &isFullscreen](bool enable) {
SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0);
isFullscreen = enable;
};
ctx.queryFullscreen = [window]() -> bool {
return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0;
};
ctx.requestQuit = [&running]() {
running = false;
};
auto ensureScoresLoaded = [&]() {
if (scoreLoader.joinable()) {
scoreLoader.join();
}
if (!ctx.scores) {
ctx.scores = &scores;
}
};
auto beginStateFade = [&](AppState targetState, bool armGameplayCountdown) {
if (!ctx.stateManager) {
return;
}
if (state == targetState) {
return;
}
if (menuFadePhase != MenuFadePhase::None) {
return;
}
menuFadePhase = MenuFadePhase::FadeOut;
menuFadeClockMs = 0.0;
menuFadeAlpha = 0.0f;
menuFadeTarget = targetState;
menuPlayCountdownArmed = armGameplayCountdown;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
if (!armGameplayCountdown) {
game.setPaused(false);
}
};
auto startMenuPlayTransition = [&]() {
if (!ctx.stateManager) {
return;
}
if (state != AppState::Menu) {
state = AppState::Playing;
ctx.stateManager->setState(state);
return;
}
beginStateFade(AppState::Playing, true);
};
ctx.startPlayTransition = startMenuPlayTransition;
auto requestStateFade = [&](AppState targetState) {
if (!ctx.stateManager) {
return;
}
if (targetState == AppState::Playing) {
startMenuPlayTransition();
return;
}
beginStateFade(targetState, false);
};
ctx.requestFadeTransition = requestStateFade;
// Instantiate state objects
auto loadingState = std::make_unique<LoadingState>(ctx);
auto menuState = std::make_unique<MenuState>(ctx);
auto optionsState = std::make_unique<OptionsState>(ctx);
auto levelSelectorState = std::make_unique<LevelSelectorState>(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(); g_loadingStarted.store(true); });
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(); });
stateMgr.registerHandler(AppState::Options, [&](const SDL_Event& e){ optionsState->handleEvent(e); });
stateMgr.registerOnEnter(AppState::Options, [&](){ optionsState->onEnter(); });
stateMgr.registerOnExit(AppState::Options, [&](){ optionsState->onExit(); });
stateMgr.registerHandler(AppState::LevelSelector, [&](const SDL_Event& e){ levelSelectorState->handleEvent(e); });
stateMgr.registerOnEnter(AppState::LevelSelector, [&](){ levelSelectorState->onEnter(); });
stateMgr.registerOnExit(AppState::LevelSelector, [&](){ levelSelectorState->onExit(); });
// Combined Playing state handler: run playingState handler
stateMgr.registerHandler(AppState::Playing, [&](const SDL_Event& e){
// First give the PlayingState a chance to handle the event
playingState->handleEvent(e);
});
stateMgr.registerOnEnter(AppState::Playing, [&](){ playingState->onEnter(); });
stateMgr.registerOnExit(AppState::Playing, [&](){ playingState->onExit(); });
// Manually trigger the initial Loading state's onEnter
loadingState->onEnter();
g_loadingStarted.store(true);
// Playing, LevelSelect and GameOver currently use inline logic in main; we'll migrate later
while (running)
{
if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) {
ensureScoresLoaded();
}
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 || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)
running = false;
else {
// Route event to state manager handlers for per-state logic
const bool isUserInputEvent =
e.type == SDL_EVENT_KEY_DOWN ||
e.type == SDL_EVENT_KEY_UP ||
e.type == SDL_EVENT_TEXT_INPUT ||
e.type == SDL_EVENT_MOUSE_BUTTON_DOWN ||
e.type == SDL_EVENT_MOUSE_BUTTON_UP ||
e.type == SDL_EVENT_MOUSE_MOTION;
if (!(showHelpOverlay && isUserInputEvent)) {
stateMgr.handleEvent(e);
// Keep the local `state` variable in sync with StateManager in case
// a state handler requested a transition (handlers may call
// stateMgr.setState()). Many branches below rely on the local
// `state` variable, so update it immediately after handling.
state = stateMgr.getState();
}
// 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;
Settings::instance().setMusicEnabled(musicEnabled);
}
if (e.key.scancode == SDL_SCANCODE_N)
{
Audio::instance().skipToNextTrack();
if (!musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
musicStarted = true;
musicEnabled = true;
Settings::instance().setMusicEnabled(true);
}
}
if (e.key.scancode == SDL_SCANCODE_S)
{
// Toggle sound effects
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
}
// Disable H-help shortcut on the main menu; keep it elsewhere
if (e.key.scancode == SDL_SCANCODE_H && state != AppState::Loading && state != AppState::Menu)
{
showHelpOverlay = !showHelpOverlay;
if (state == AppState::Playing) {
if (showHelpOverlay) {
if (!game.isPaused()) {
game.setPaused(true);
helpOverlayPausedGame = true;
} else {
helpOverlayPausedGame = false;
}
} else if (helpOverlayPausedGame) {
game.setPaused(false);
helpOverlayPausedGame = false;
}
} else if (!showHelpOverlay) {
helpOverlayPausedGame = false;
}
}
// If help overlay is visible and the user presses ESC, close help and return to Menu
if (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) {
showHelpOverlay = false;
helpOverlayPausedGame = false;
// Unpause game if we paused it for the overlay
if (state == AppState::Playing) {
if (game.isPaused() && !helpOverlayPausedGame) {
// If paused for other reasons, avoid overriding; otherwise ensure unpaused
// (The flag helps detect pause because of help overlay.)
}
}
if (state != AppState::Menu && ctx.requestFadeTransition) {
// Request a transition back to the Menu state
ctx.requestFadeTransition(AppState::Menu);
} else if (state != AppState::Menu && ctx.stateManager) {
state = AppState::Menu;
ctx.stateManager->setState(state);
}
}
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);
Settings::instance().setFullscreen(isFullscreen);
}
if (e.key.scancode == SDL_SCANCODE_F5)
{
warpAutoPilotEnabled = false;
warpFlightMode = SpaceWarpFlightMode::Forward;
spaceWarp.setFlightMode(warpFlightMode);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: forward");
}
if (e.key.scancode == SDL_SCANCODE_F6)
{
warpAutoPilotEnabled = false;
warpFlightMode = SpaceWarpFlightMode::BankLeft;
spaceWarp.setFlightMode(warpFlightMode);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank left");
}
if (e.key.scancode == SDL_SCANCODE_F7)
{
warpAutoPilotEnabled = false;
warpFlightMode = SpaceWarpFlightMode::BankRight;
spaceWarp.setFlightMode(warpFlightMode);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank right");
}
if (e.key.scancode == SDL_SCANCODE_F8)
{
warpAutoPilotEnabled = false;
warpFlightMode = SpaceWarpFlightMode::Reverse;
spaceWarp.setFlightMode(warpFlightMode);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: reverse");
}
if (e.key.scancode == SDL_SCANCODE_F9)
{
warpAutoPilotEnabled = true;
spaceWarp.setAutoPilotEnabled(true);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp autopilot engaged");
}
}
// Text input for high score
if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
if (playerName.length() < 12) {
playerName += e.text.text;
}
}
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (isNewHighScore) {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
playerName.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (playerName.empty()) playerName = "PLAYER";
ensureScoresLoaded();
scores.submit(game.score(), game.lines(), game.level(), game.elapsed(), playerName);
Settings::instance().setPlayerName(playerName);
isNewHighScore = false;
SDL_StopTextInput(window);
}
} else {
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
// Restart
game.reset(startLevelSelection);
state = AppState::Playing;
stateMgr.setState(state);
} else if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
// Menu
state = AppState::Menu;
stateMgr.setState(state);
}
}
}
// Mouse handling remains in main loop for UI interactions
if (!showHelpOverlay && 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 (showSettingsPopup) {
// Click anywhere closes settings popup
showSettingsPopup = false;
} else {
ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale };
auto buttonRects = ui::computeMenuButtonRects(params);
auto pointInRect = [&](const SDL_FRect& r) {
return lx >= r.x && lx <= r.x + r.w && ly >= r.y && ly <= r.y + r.h;
};
if (pointInRect(buttonRects[0])) {
startMenuPlayTransition();
} else if (pointInRect(buttonRects[1])) {
requestStateFade(AppState::LevelSelector);
} else if (pointInRect(buttonRects[2])) {
requestStateFade(AppState::Options);
} else if (pointInRect(buttonRects[3])) {
// HELP - show inline help HUD in the MenuState
if (menuState) menuState->showHelpPanel(true);
} else if (pointInRect(buttonRects[4])) {
showExitConfirmPopup = true;
exitPopupSelectedButton = 1;
}
// Settings button (gear icon area - top right)
SDL_FRect settingsBtn{SETTINGS_BTN_X, SETTINGS_BTN_Y, SETTINGS_BTN_W, SETTINGS_BTN_H};
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 = 400, popupH = 200;
float popupX = (LOGICAL_W - popupW) / 2.0f;
float popupY = (LOGICAL_H - popupH) / 2.0f;
// Simple Yes/No buttons
float btnW = 120.0f, btnH = 40.0f;
float yesX = popupX + popupW * 0.25f - btnW / 2.0f;
float noX = popupX + popupW * 0.75f - btnW / 2.0f;
float btnY = popupY + popupH - btnH - 20.0f;
if (localX >= popupX && localX <= popupX + popupW && localY >= popupY && localY <= popupY + popupH) {
// Click inside popup - check buttons
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 (state == AppState::Menu && showExitConfirmPopup) {
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.0f;
float popupH = 230.0f;
float popupX = (LOGICAL_W - popupW) * 0.5f + contentOffsetX;
float popupY = (LOGICAL_H - popupH) * 0.5f + contentOffsetY;
float btnW = 140.0f;
float btnH = 50.0f;
float yesX = popupX + popupW * 0.3f - btnW / 2.0f;
float noX = popupX + popupW * 0.7f - btnW / 2.0f;
float btnY = popupY + popupH - btnH - 30.0f;
bool insidePopup = lx >= popupX && lx <= popupX + popupW && ly >= popupY && ly <= popupY + popupH;
if (insidePopup) {
if (lx >= yesX && lx <= yesX + btnW && ly >= btnY && ly <= btnY + btnH) {
showExitConfirmPopup = false;
running = false;
} else if (lx >= noX && lx <= noX + btnW && ly >= btnY && ly <= btnY + btnH) {
showExitConfirmPopup = false;
}
} else {
showExitConfirmPopup = false;
}
}
}
}
else if (!showHelpOverlay && 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 && !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;
ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale };
hoveredButton = ui::hitTestMenuButtons(params, lx, ly);
}
}
}
}
}
// --- Timing ---
Uint64 now = SDL_GetPerformanceCounter();
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
lastMs = now;
// Cap frame time to avoid spiral of death (max 100ms)
if (frameMs > 100.0) frameMs = 100.0;
gameplayBackgroundClockMs += frameMs;
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);
// Track music loading on every frame so it finishes even after the loading screen ends
if (musicLoadingStarted && !musicLoaded) {
currentTrackLoading = Audio::instance().getLoadedTrackCount();
if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) {
Audio::instance().shuffle();
// Defer starting playback until the app has entered the Menu/Playing state.
// Actual playback is started below when `musicLoaded` is observed and
// the state is Menu or Playing (so the user doesn't hear music while
// still on the Loading screen).
musicLoaded = true;
}
}
if (state == AppState::Playing)
{
if (!game.isPaused()) {
game.tickGravity(frameMs);
game.updateElapsedTime();
// 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())
{
// Always allow name entry if score > 0
if (game.score() > 0) {
isNewHighScore = true; // Reuse flag to trigger input mode
playerName = "";
SDL_StartTextInput(window);
} else {
isNewHighScore = false;
ensureScoresLoaded();
scores.submit(game.score(), game.lines(), game.level(), game.elapsed());
}
state = AppState::GameOver;
stateMgr.setState(state);
}
}
else if (state == AppState::Loading)
{
static int queuedTextureCount = 0;
// Execute one loading step per frame on main thread via LoadingManager
if (g_loadingStarted.load() && !g_loadingComplete.load()) {
static bool queuedTextures = false;
static std::vector<std::string> queuedPaths;
if (!queuedTextures) {
queuedTextures = true;
// Initialize counters and clear previous errors
constexpr int baseTasks = 25; // keep same budget as before
g_totalLoadingTasks.store(baseTasks);
g_loadedTasks.store(0);
{
std::lock_guard<std::mutex> lk(g_assetLoadErrorsMutex);
g_assetLoadErrors.clear();
}
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile.clear();
}
// Initialize background music loading
Audio::instance().init();
totalTracks = 0;
for (int i = 1; i <= 100; ++i) {
char base[128];
std::snprintf(base, sizeof(base), "assets/music/music%03d", i);
std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" });
if (path.empty()) break;
Audio::instance().addTrackAsync(path);
totalTracks++;
}
g_totalLoadingTasks.store(baseTasks + totalTracks);
if (totalTracks > 0) {
Audio::instance().startBackgroundLoading();
musicLoadingStarted = true;
} else {
musicLoaded = true;
}
// Initialize fonts (synchronous, cheap)
pixelFont.init(AssetPath::resolveWithBase("assets/fonts/Orbitron.ttf"), 22);
g_loadedTasks.fetch_add(1);
font.init(AssetPath::resolveWithBase("assets/fonts/Exo2.ttf"), 20);
g_loadedTasks.fetch_add(1);
// Queue UI textures for incremental loading
queuedPaths = {
"assets/images/spacetris.png",
"assets/images/spacetris.png", // small logo uses same source
"assets/images/main_screen.png",
"assets/images/blocks90px_001.bmp",
"assets/images/panel_score.png",
"assets/images/statistics_panel.png",
"assets/images/next_panel.png"
};
for (auto &p : queuedPaths) {
loadingManager.queueTexture(p);
}
queuedTextureCount = static_cast<int>(queuedPaths.size());
// Initialize sound effects manager (counts as a loaded task)
SoundEffectManager::instance().init();
g_loadedTasks.fetch_add(1);
// Load small set of voice/audio SFX synchronously for now (keeps behavior)
const std::vector<std::string> audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level"};
for (const auto &id : audioIds) {
std::string basePath = "assets/music/" + (id == "hard_drop" ? "hard_drop_001" : id);
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile = basePath;
}
std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" });
if (!resolved.empty()) {
SoundEffectManager::instance().loadSound(id, resolved);
}
g_loadedTasks.fetch_add(1);
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
g_currentLoadingFile.clear();
}
}
}
// Perform a single texture loading step via LoadingManager
bool texturesDone = loadingManager.update();
if (texturesDone) {
// Bind loaded textures into the runtime context
logoTex = assetLoader.getTexture("assets/images/spacetris.png");
logoSmallTex = assetLoader.getTexture("assets/images/spacetris.png");
mainScreenTex = assetLoader.getTexture("assets/images/main_screen.png");
blocksTex = assetLoader.getTexture("assets/images/blocks90px_001.bmp");
scorePanelTex = assetLoader.getTexture("assets/images/panel_score.png");
statisticsPanelTex = assetLoader.getTexture("assets/images/statistics_panel.png");
nextPanelTex = assetLoader.getTexture("assets/images/next_panel.png");
auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) {
if (!tex) return;
if (outW > 0 && outH > 0) return;
float w = 0.0f, h = 0.0f;
if (SDL_GetTextureSize(tex, &w, &h)) {
outW = static_cast<int>(std::lround(w));
outH = static_cast<int>(std::lround(h));
}
};
// If a texture was created by AssetLoader (not legacy IMG_Load),
// its stored width/height may still be 0. Query the real size.
ensureTextureSize(logoSmallTex, logoSmallW, logoSmallH);
ensureTextureSize(mainScreenTex, mainScreenW, mainScreenH);
// Fallback: if any critical UI texture failed to load via AssetLoader,
// load synchronously using the legacy helper so the Menu can render.
auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) {
if (!outTex) {
outTex = loadTextureFromImage(renderer, p, outW, outH);
}
};
legacyLoad("assets/images/spacetris.png", logoTex);
legacyLoad("assets/images/spacetris.png", logoSmallTex, &logoSmallW, &logoSmallH);
legacyLoad("assets/images/main_screen.png", mainScreenTex, &mainScreenW, &mainScreenH);
legacyLoad("assets/images/blocks90px_001.bmp", blocksTex);
legacyLoad("assets/images/panel_score.png", scorePanelTex);
legacyLoad("assets/images/statistics_panel.png", statisticsPanelTex);
legacyLoad("assets/images/next_panel.png", nextPanelTex);
// If blocks texture failed, create fallback and count it as loaded
if (!blocksTex) {
blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90);
SDL_SetRenderTarget(renderer, blocksTex);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
for (int i = 0; i < PIECE_COUNT; ++i) {
SDL_Color c = COLORS[i + 1];
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
SDL_FRect rect{(float)(i * 90), 0, 90, 90};
SDL_RenderFillRect(renderer, &rect);
}
SDL_SetRenderTarget(renderer, nullptr);
// Do not update global task counter here; textures are accounted
// for via the LoadingManager/AssetLoader progress below.
}
// Mark loading complete when music also loaded
if (musicLoaded) {
g_loadingComplete.store(true);
}
}
}
// Prefer task-based progress if we have tasks registered
const int totalTasks = g_totalLoadingTasks.load(std::memory_order_acquire);
const int musicDone = std::min(totalTracks, currentTrackLoading);
int doneTasks = g_loadedTasks.load(std::memory_order_acquire) + musicDone;
// Include texture progress reported by the LoadingManager/AssetLoader
if (queuedTextureCount > 0) {
float texProg = loadingManager.getProgress();
int texDone = static_cast<int>(std::floor(texProg * queuedTextureCount + 0.5f));
if (texDone > queuedTextureCount) texDone = queuedTextureCount;
doneTasks += texDone;
}
if (doneTasks > totalTasks) doneTasks = totalTasks;
if (totalTasks > 0) {
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
if (loadingProgress >= 1.0 && musicLoaded) {
state = AppState::Menu;
stateMgr.setState(state);
}
} else {
// Fallback: time + audio heuristics (legacy behavior)
double assetProgress = 0.2;
double musicProgress = 0.0;
if (totalTracks > 0) {
musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7);
} else {
if (Audio::instance().isLoadingComplete()) {
musicProgress = 0.7;
} else if (Audio::instance().getLoadedTrackCount() > 0) {
musicProgress = 0.35;
} else {
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
if (elapsedMs > 1500) {
musicProgress = 0.7;
musicLoaded = true;
} else {
musicProgress = 0.0;
}
}
}
double timeProgress = std::min(0.1, (now - loadStart) / 500.0);
loadingProgress = std::min(1.0, assetProgress + musicProgress + timeProgress);
if (loadingProgress > 0.99) loadingProgress = 1.0;
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)
{
// Load menu track once on first menu entry (in background to avoid blocking)
static bool menuTrackLoaded = false;
if (!menuTrackLoaded) {
if (menuTrackLoader.joinable()) {
menuTrackLoader.join();
}
menuTrackLoader = std::jthread([]() {
std::string menuTrack = AssetPath::resolveWithExtensions("assets/music/Every Block You Take", { ".mp3" });
if (!menuTrack.empty()) {
Audio::instance().setMenuTrack(menuTrack);
} else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Menu track not found (Every Block You Take)");
}
});
menuTrackLoaded = true;
}
// Start appropriate music based on state
if (state == AppState::Menu) {
Audio::instance().playMenuMusic();
} else {
Audio::instance().playGameMusic();
}
musicStarted = true;
}
}
// Handle music transitions between states
static AppState previousState = AppState::Loading;
if (state != previousState && musicStarted) {
if (state == AppState::Menu && previousState == AppState::Playing) {
// Switched from game to menu
Audio::instance().playMenuMusic();
} else if (state == AppState::Playing && previousState == AppState::Menu) {
// Switched from menu to game
Audio::instance().playGameMusic();
}
}
previousState = state;
// Update background effects
if (state == AppState::Loading) {
starfield3D.update(float(frameMs / 1000.0f));
starfield3D.resize(winW, winH);
} else {
starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h);
}
if (state == AppState::Menu) {
spaceWarp.resize(winW, winH);
spaceWarp.update(float(frameMs / 1000.0f));
}
// Advance level background fade if a next texture is queued
levelBackgrounds.update(float(frameMs));
// Update intro animations
if (state == AppState::Menu) {
logoAnimCounter += frameMs * 0.0008; // Animation speed
}
// --- 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::Options:
optionsState->update(frameMs);
break;
case AppState::LevelSelector:
levelSelectorState->update(frameMs);
break;
case AppState::Playing:
playingState->update(frameMs);
break;
default:
break;
}
// Keep context asset pointers in sync with assets loaded by the loader thread
ctx.logoTex = logoTex;
ctx.logoSmallTex = logoSmallTex;
ctx.logoSmallW = logoSmallW;
ctx.logoSmallH = logoSmallH;
ctx.backgroundTex = backgroundTex;
ctx.blocksTex = blocksTex;
ctx.scorePanelTex = scorePanelTex;
ctx.statisticsPanelTex = statisticsPanelTex;
ctx.nextPanelTex = nextPanelTex;
ctx.mainScreenTex = mainScreenTex;
ctx.mainScreenW = mainScreenW;
ctx.mainScreenH = mainScreenH;
if (menuFadePhase == MenuFadePhase::FadeOut) {
menuFadeClockMs += frameMs;
menuFadeAlpha = std::min(1.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS));
if (menuFadeClockMs >= MENU_PLAY_FADE_DURATION_MS) {
if (state != menuFadeTarget) {
state = menuFadeTarget;
stateMgr.setState(state);
}
if (menuFadeTarget == AppState::Playing) {
menuPlayCountdownArmed = true;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
game.setPaused(true);
} else {
menuPlayCountdownArmed = false;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
game.setPaused(false);
}
menuFadePhase = MenuFadePhase::FadeIn;
menuFadeClockMs = MENU_PLAY_FADE_DURATION_MS;
menuFadeAlpha = 1.0f;
}
} else if (menuFadePhase == MenuFadePhase::FadeIn) {
menuFadeClockMs -= frameMs;
menuFadeAlpha = std::max(0.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS));
if (menuFadeClockMs <= 0.0) {
menuFadePhase = MenuFadePhase::None;
menuFadeClockMs = 0.0;
menuFadeAlpha = 0.0f;
}
}
if (menuFadePhase == MenuFadePhase::None && menuPlayCountdownArmed && !gameplayCountdownActive && state == AppState::Playing) {
gameplayCountdownActive = true;
menuPlayCountdownArmed = false;
gameplayCountdownElapsed = 0.0;
gameplayCountdownIndex = 0;
game.setPaused(true);
}
if (gameplayCountdownActive && state == AppState::Playing) {
gameplayCountdownElapsed += frameMs;
if (gameplayCountdownElapsed >= GAMEPLAY_COUNTDOWN_STEP_MS) {
gameplayCountdownElapsed -= GAMEPLAY_COUNTDOWN_STEP_MS;
++gameplayCountdownIndex;
if (gameplayCountdownIndex >= static_cast<int>(GAMEPLAY_COUNTDOWN_LABELS.size())) {
gameplayCountdownActive = false;
gameplayCountdownElapsed = 0.0;
gameplayCountdownIndex = 0;
game.setPaused(false);
}
}
}
if (state != AppState::Playing && gameplayCountdownActive) {
gameplayCountdownActive = false;
menuPlayCountdownArmed = false;
gameplayCountdownElapsed = 0.0;
gameplayCountdownIndex = 0;
game.setPaused(false);
}
// --- Render ---
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
// Draw level-based background for gameplay, starfield for other states
if (state == AppState::Playing) {
int bgLevel = std::clamp(game.level(), 0, 32);
levelBackgrounds.queueLevelBackground(renderer, bgLevel);
levelBackgrounds.render(renderer, winW, winH, static_cast<float>(gameplayBackgroundClockMs));
} else if (state == AppState::Loading) {
// Use 3D starfield for loading screen (full screen)
starfield3D.draw(renderer);
} else if (state == AppState::Menu) {
// Space flyover backdrop for the main screen
spaceWarp.draw(renderer, 1.0f);
// `mainScreenTex` is rendered as a top layer just before presenting
// so we don't draw it here. Keep the space warp background only.
} else if (state == AppState::LevelSelector || state == AppState::Options) {
// No static background texture to draw (background image removed).
} 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});
// If any asset/audio errors occurred during startup, display recent ones in red
{
std::lock_guard<std::mutex> lk(g_assetLoadErrorsMutex);
const int maxShow = 5;
int count = static_cast<int>(g_assetLoadErrors.size());
if (count > 0) {
int start = std::max(0, count - maxShow);
float errY = currentY + spacingBetweenElements + 8.0f;
// Also make a visible window title change so users notice missing assets
std::string latest = g_assetLoadErrors.back();
std::string shortTitle = "Tetris - Missing assets";
if (!latest.empty()) {
std::string trimmed = latest;
if (trimmed.size() > 48) trimmed = trimmed.substr(0, 45) + "...";
shortTitle += ": ";
shortTitle += trimmed;
}
SDL_SetWindowTitle(window, shortTitle.c_str());
// Also append a trace log entry for visibility outside the SDL window
FILE* tf = fopen("tetris_trace.log", "a");
if (tf) {
fprintf(tf, "Loading error: %s\n", g_assetLoadErrors.back().c_str());
fclose(tf);
}
for (int i = start; i < count; ++i) {
const std::string& msg = g_assetLoadErrors[i];
// Truncate long messages to fit reasonably
std::string display = msg;
if (display.size() > 80) display = display.substr(0, 77) + "...";
pixelFont.draw(renderer, 80 + contentOffsetX, errY + contentOffsetY, display.c_str(), 0.85f, {255, 100, 100, 255});
errY += 20.0f;
}
}
}
// Debug overlay: show current loading file and counters when enabled in settings
if (Settings::instance().isDebugEnabled()) {
std::string cur;
{
std::lock_guard<std::mutex> lk(g_currentLoadingMutex);
cur = g_currentLoadingFile;
}
char buf[128];
int loaded = g_loadedTasks.load();
int total = g_totalLoadingTasks.load();
std::snprintf(buf, sizeof(buf), "Loaded: %d / %d", loaded, total);
float debugX = 20.0f + contentOffsetX;
float debugY = LOGICAL_H - 48.0f + contentOffsetY;
pixelFont.draw(renderer, debugX, debugY, buf, 0.9f, SDL_Color{200,200,200,255});
if (!cur.empty()) {
std::string display = "Loading: ";
display += cur;
if (display.size() > 80) display = display.substr(0,77) + "...";
pixelFont.draw(renderer, debugX, debugY + 18.0f, display.c_str(), 0.85f, SDL_Color{200,180,120,255});
}
}
}
break;
case AppState::Menu:
// Ensure overlay is loaded (drawn after highscores so it sits above that layer)
if (!mainScreenTex) {
mainScreenTex = loadTextureFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH);
}
// Render menu content that should appear *behind* the overlay (highscores/logo).
// Bottom buttons are drawn separately on top.
if (menuState) {
menuState->drawMainButtonNormally = false;
menuState->render(renderer, logicalScale, logicalVP);
}
// Draw main screen overlay above highscores
if (mainScreenTex) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.f, 1.f);
float texW = mainScreenW > 0 ? static_cast<float>(mainScreenW) : 0.0f;
float texH = mainScreenH > 0 ? static_cast<float>(mainScreenH) : 0.0f;
if (texW <= 0.0f || texH <= 0.0f) {
float iwf = 0.0f, ihf = 0.0f;
if (!SDL_GetTextureSize(mainScreenTex, &iwf, &ihf)) {
iwf = ihf = 0.0f;
}
texW = iwf;
texH = ihf;
}
if (texW > 0.0f && texH > 0.0f) {
const float drawH = static_cast<float>(winH);
const float scale = drawH / texH;
const float drawW = texW * scale;
SDL_FRect dst{
(winW - drawW) * 0.5f,
0.0f,
drawW,
drawH
};
SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND);
SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst);
}
SDL_SetRenderViewport(renderer, &logicalVP);
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
}
// Draw bottom menu buttons above the overlay
if (menuState) {
menuState->renderMainButtonTop(renderer, logicalScale, logicalVP);
}
break;
case AppState::Options:
optionsState->render(renderer, logicalScale, logicalVP);
break;
case AppState::LevelSelector:
// Delegate level selector rendering to LevelSelectorState
levelSelectorState->render(renderer, logicalScale, logicalVP);
break;
case AppState::LevelSelect:
{
const std::string title = "SELECT LEVEL";
int tW = 0, tH = 0;
font.measure(title, 2.5f, tW, tH);
float titleX = (LOGICAL_W - (float)tW) / 2.0f;
font.draw(renderer, titleX, 80, title, 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:
playingState->render(renderer, logicalScale, logicalVP);
break;
case AppState::GameOver:
// Draw the game state in the background
GameRenderer::renderPlayingState(
renderer,
&game,
&pixelFont,
&lineEffect,
blocksTex,
ctx.statisticsPanelTex,
scorePanelTex,
nextPanelTex,
(float)LOGICAL_W,
(float)LOGICAL_H,
logicalScale,
(float)winW,
(float)winH
);
// Draw Game Over Overlay
{
// 1. Dim the background
SDL_SetRenderViewport(renderer, nullptr); // Use window coordinates for full screen dim
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); // Dark semi-transparent
SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &fullWin);
// Restore logical viewport
SDL_SetRenderViewport(renderer, &logicalVP);
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
// 2. Calculate content offsets (same as in GameRenderer)
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;
// 3. Draw Game Over Box
float boxW = 500.0f;
float boxH = 350.0f;
float boxX = (LOGICAL_W - boxW) * 0.5f;
float boxY = (LOGICAL_H - boxH) * 0.5f;
// Draw box background
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH};
SDL_RenderFillRect(renderer, &boxRect);
// Draw box border
SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255);
SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6};
SDL_RenderFillRect(renderer, &borderRect); // Use FillRect for border background effect
SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255);
SDL_RenderFillRect(renderer, &boxRect); // Redraw background on top of border rect
// 4. Draw Text
// 4. Draw Text
// Title
ensureScoresLoaded();
bool realHighScore = scores.isHighScore(game.score());
const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER";
int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH);
pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255});
// Score
char scoreStr[64];
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game.score());
int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH);
pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255});
if (isNewHighScore) {
// Name Entry
const char* enterName = "ENTER NAME:";
int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH);
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
// Input box
float inputW = 300.0f;
float inputH = 40.0f;
float inputX = boxX + (boxW - inputW) * 0.5f;
float inputY = boxY + 200.0f;
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
SDL_RenderFillRect(renderer, &inputRect);
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
SDL_RenderRect(renderer, &inputRect);
// Player Name (blink cursor without shifting text)
const float nameScale = 1.2f;
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
int metricsW = 0, metricsH = 0;
pixelFont.measure("A", nameScale, metricsW, metricsH);
if (metricsH == 0) metricsH = 24; // fallback height
int nameW = 0, nameH = 0;
if (!playerName.empty()) {
pixelFont.measure(playerName, nameScale, nameW, nameH);
} else {
nameH = metricsH;
}
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
if (!playerName.empty()) {
pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255});
}
if (showCursor) {
int cursorW = 0, cursorH = 0;
pixelFont.measure("_", nameScale, cursorW, cursorH);
float cursorX = playerName.empty()
? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX
: textX + static_cast<float>(nameW);
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255});
}
// Hint
const char* hint = "PRESS ENTER TO SUBMIT";
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
} else {
// Lines
char linesStr[64];
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game.lines());
int lW=0, lH=0; pixelFont.measure(linesStr, 1.2f, lW, lH);
pixelFont.draw(renderer, boxX + (boxW - lW) * 0.5f + contentOffsetX, boxY + 140 + contentOffsetY, linesStr, 1.2f, {255, 255, 255, 255});
// Level
char levelStr[64];
snprintf(levelStr, sizeof(levelStr), "LEVEL: %d", game.level());
int lvW=0, lvH=0; pixelFont.measure(levelStr, 1.2f, lvW, lvH);
pixelFont.draw(renderer, boxX + (boxW - lvW) * 0.5f + contentOffsetX, boxY + 180 + contentOffsetY, levelStr, 1.2f, {255, 255, 255, 255});
// Instructions
const char* instr = "PRESS ENTER TO RESTART";
int iW=0, iH=0; pixelFont.measure(instr, 0.9f, iW, iH);
pixelFont.draw(renderer, boxX + (boxW - iW) * 0.5f + contentOffsetX, boxY + 260 + contentOffsetY, instr, 0.9f, {255, 220, 0, 255});
const char* instr2 = "PRESS ESC FOR MENU";
int iW2=0, iH2=0; pixelFont.measure(instr2, 0.9f, iW2, iH2);
pixelFont.draw(renderer, boxX + (boxW - iW2) * 0.5f + contentOffsetX, boxY + 290 + contentOffsetY, instr2, 0.9f, {255, 220, 0, 255});
}
}
break;
}
if (menuFadeAlpha > 0.0f) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.f, 1.f);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
Uint8 alpha = Uint8(std::clamp(menuFadeAlpha, 0.0f, 1.0f) * 255.0f);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, alpha);
SDL_FRect fadeRect{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &fadeRect);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
SDL_SetRenderViewport(renderer, &logicalVP);
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
}
if (gameplayCountdownActive && state == AppState::Playing) {
// Switch to window coordinates for perfect centering in any resolution
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.f, 1.f);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
int cappedIndex = std::min(gameplayCountdownIndex, static_cast<int>(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1);
const char* label = GAMEPLAY_COUNTDOWN_LABELS[cappedIndex];
bool isFinalCue = (cappedIndex == static_cast<int>(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1);
float textScale = isFinalCue ? 4.5f : 5.0f; // Much bigger fonts for countdown
int textW = 0, textH = 0;
pixelFont.measure(label, textScale, textW, textH);
// Center in actual window coordinates (works for any resolution/fullscreen)
float textX = (winW - static_cast<float>(textW)) * 0.5f;
float textY = (winH - static_cast<float>(textH)) * 0.5f;
SDL_Color textColor = isFinalCue ? SDL_Color{255, 230, 90, 255} : SDL_Color{255, 255, 255, 255};
pixelFont.draw(renderer, textX, textY, label, textScale, textColor);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}
if (showHelpOverlay) {
SDL_SetRenderViewport(renderer, &logicalVP);
SDL_SetRenderScale(renderer, logicalScale, logicalScale);
float contentOffsetX = 0.0f;
float contentOffsetY = 0.0f;
if (logicalScale > 0.0f) {
float scaledW = LOGICAL_W * logicalScale;
float scaledH = LOGICAL_H * logicalScale;
contentOffsetX = (winW - scaledW) * 0.5f / logicalScale;
contentOffsetY = (winH - scaledH) * 0.5f / logicalScale;
}
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
}
SDL_RenderPresent(renderer);
SDL_SetRenderScale(renderer, 1.f, 1.f);
}
if (logoTex)
SDL_DestroyTexture(logoTex);
if (mainScreenTex)
SDL_DestroyTexture(mainScreenTex);
levelBackgrounds.reset();
if (blocksTex)
SDL_DestroyTexture(blocksTex);
if (scorePanelTex)
SDL_DestroyTexture(scorePanelTex);
if (logoSmallTex)
SDL_DestroyTexture(logoSmallTex);
// Save settings on exit
Settings::instance().save();
if (scoreLoader.joinable()) {
scoreLoader.join();
if (!ctx.scores) {
ctx.scores = &scores;
}
}
if (menuTrackLoader.joinable()) {
menuTrackLoader.join();
}
lineEffect.shutdown();
Audio::instance().shutdown();
SoundEffectManager::instance().shutdown();
font.shutdown();
TTF_Quit();
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}