diff --git a/CMakeLists.txt b/CMakeLists.txt index ba39b6f..b27dce9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ find_package(nlohmann_json CONFIG REQUIRED) set(TETRIS_SOURCES src/main.cpp + src/app/TetrisApp.cpp src/gameplay/core/Game.cpp src/core/GravityManager.cpp src/core/state/StateManager.cpp @@ -52,6 +53,13 @@ set(TETRIS_SOURCES src/audio/Audio.cpp src/gameplay/effects/LineEffect.cpp src/audio/SoundEffect.cpp + src/ui/MenuLayout.cpp + src/ui/BottomMenu.cpp + src/app/BackgroundManager.cpp + src/app/Fireworks.cpp + src/app/AssetLoader.cpp + src/app/TextureLoader.cpp + src/states/LoadingManager.cpp # State implementations (new) src/states/LoadingState.cpp src/states/MenuState.cpp @@ -60,6 +68,7 @@ set(TETRIS_SOURCES src/states/PlayingState.cpp ) + if(APPLE) set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns") if(EXISTS "${APP_ICON}") diff --git a/assets/images/blocks90px_002.png b/assets/images/blocks90px_002.png new file mode 100644 index 0000000..feb5a49 Binary files /dev/null and b/assets/images/blocks90px_002.png differ diff --git a/assets/images/blocks90px_003.png b/assets/images/blocks90px_003.png new file mode 100644 index 0000000..3757a67 Binary files /dev/null and b/assets/images/blocks90px_003.png differ diff --git a/assets/images/hold_panel.png b/assets/images/hold_panel.png new file mode 100644 index 0000000..191ef4a Binary files /dev/null and b/assets/images/hold_panel.png differ diff --git a/assets/images/main_screen.png b/assets/images/main_screen.png index 3f593b8..fb37204 100644 Binary files a/assets/images/main_screen.png and b/assets/images/main_screen.png differ diff --git a/assets/images/main_screen_old.png b/assets/images/main_screen_old.png new file mode 100644 index 0000000..3f593b8 Binary files /dev/null and b/assets/images/main_screen_old.png differ diff --git a/assets/images/blocks001.bmp b/assets/images/old/blocks001.bmp similarity index 100% rename from assets/images/blocks001.bmp rename to assets/images/old/blocks001.bmp diff --git a/assets/images/blocks001.png b/assets/images/old/blocks001.png similarity index 100% rename from assets/images/blocks001.png rename to assets/images/old/blocks001.png diff --git a/assets/images/blocks3.bmp b/assets/images/old/blocks3.bmp similarity index 100% rename from assets/images/blocks3.bmp rename to assets/images/old/blocks3.bmp diff --git a/assets/images/blocks3.png b/assets/images/old/blocks3.png similarity index 100% rename from assets/images/blocks3.png rename to assets/images/old/blocks3.png diff --git a/assets/images/blocks90px_001.bmp b/assets/images/old/blocks90px_001.bmp similarity index 100% rename from assets/images/blocks90px_001.bmp rename to assets/images/old/blocks90px_001.bmp diff --git a/settings.ini b/settings.ini index cf57a4c..99029f7 100644 --- a/settings.ini +++ b/settings.ini @@ -14,7 +14,7 @@ SmoothScroll=1 UpRotateClockwise=0 [Player] -Name=PLAYER +Name=GREGOR [Debug] Enabled=1 diff --git a/src/app/AssetLoader.cpp b/src/app/AssetLoader.cpp new file mode 100644 index 0000000..95d060e --- /dev/null +++ b/src/app/AssetLoader.cpp @@ -0,0 +1,139 @@ +#include "app/AssetLoader.h" +#include +#include + +AssetLoader::AssetLoader() = default; + +AssetLoader::~AssetLoader() { + shutdown(); +} + +void AssetLoader::init(SDL_Renderer* renderer) { + m_renderer = renderer; +} + +void AssetLoader::shutdown() { + // Destroy textures + { + std::lock_guard lk(m_texturesMutex); + for (auto &p : m_textures) { + if (p.second) SDL_DestroyTexture(p.second); + } + m_textures.clear(); + } + + // Clear queue and errors + { + std::lock_guard lk(m_queueMutex); + m_queue.clear(); + } + { + std::lock_guard lk(m_errorsMutex); + m_errors.clear(); + } + + m_totalTasks = 0; + m_loadedTasks = 0; + m_renderer = nullptr; +} + +void AssetLoader::setBasePath(const std::string& basePath) { + m_basePath = basePath; +} + +void AssetLoader::queueTexture(const std::string& path) { + { + std::lock_guard lk(m_queueMutex); + m_queue.push_back(path); + } + m_totalTasks.fetch_add(1, std::memory_order_relaxed); +} + +bool AssetLoader::performStep() { + std::string path; + { + std::lock_guard lk(m_queueMutex); + if (m_queue.empty()) return true; + path = m_queue.front(); + m_queue.erase(m_queue.begin()); + } + + { + std::lock_guard lk(m_currentLoadingMutex); + m_currentLoading = path; + } + + std::string fullPath = m_basePath.empty() ? path : (m_basePath + "/" + path); + + SDL_Surface* surf = IMG_Load(fullPath.c_str()); + if (!surf) { + std::lock_guard lk(m_errorsMutex); + m_errors.push_back(std::string("IMG_Load failed: ") + fullPath + " -> " + SDL_GetError()); + } else { + SDL_Texture* tex = SDL_CreateTextureFromSurface(m_renderer, surf); + SDL_DestroySurface(surf); + if (!tex) { + std::lock_guard lk(m_errorsMutex); + m_errors.push_back(std::string("CreateTexture failed: ") + fullPath); + } else { + std::lock_guard lk(m_texturesMutex); + auto& slot = m_textures[path]; + if (slot && slot != tex) { + SDL_DestroyTexture(slot); + } + slot = tex; + } + } + + m_loadedTasks.fetch_add(1, std::memory_order_relaxed); + + { + std::lock_guard lk(m_currentLoadingMutex); + m_currentLoading.clear(); + } + + // Return true when no more queued tasks + { + std::lock_guard lk(m_queueMutex); + return m_queue.empty(); + } +} + +void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) { + if (!texture) { + return; + } + + std::lock_guard lk(m_texturesMutex); + auto& slot = m_textures[path]; + if (slot && slot != texture) { + SDL_DestroyTexture(slot); + } + slot = texture; +} + +float AssetLoader::getProgress() const { + int total = m_totalTasks.load(std::memory_order_relaxed); + if (total <= 0) return 1.0f; + int loaded = m_loadedTasks.load(std::memory_order_relaxed); + return static_cast(loaded) / static_cast(total); +} + +std::vector AssetLoader::getAndClearErrors() { + std::lock_guard lk(m_errorsMutex); + std::vector out = m_errors; + m_errors.clear(); + return out; +} + +SDL_Texture* AssetLoader::getTexture(const std::string& path) const { + std::lock_guard lk(m_texturesMutex); + auto it = m_textures.find(path); + if (it == m_textures.end()) return nullptr; + return it->second; +} + +std::string AssetLoader::getCurrentLoading() const { + std::lock_guard lk(m_currentLoadingMutex); + return m_currentLoading; +} diff --git a/src/app/AssetLoader.h b/src/app/AssetLoader.h new file mode 100644 index 0000000..fac6128 --- /dev/null +++ b/src/app/AssetLoader.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Lightweight AssetLoader scaffold. +// Responsibilities: +// - Queue textures to load (main thread) and perform incremental loads via performStep(). +// - Store loaded SDL_Texture* instances and provide accessors. +// - Collect loading errors thread-safely. +// NOTE: All SDL texture creation MUST happen on the thread that owns the SDL_Renderer. +class AssetLoader { +public: + AssetLoader(); + ~AssetLoader(); + + void init(SDL_Renderer* renderer); + void shutdown(); + + void setBasePath(const std::string& basePath); + + // Queue a texture path (relative to base path) for loading. + void queueTexture(const std::string& path); + + // Perform a single loading step (load one queued asset). + // Returns true when all queued tasks are complete, false otherwise. + bool performStep(); + + // Progress in [0,1]. If no tasks, returns 1.0f. + float getProgress() const; + + // Retrieve and clear accumulated error messages. + std::vector getAndClearErrors(); + + // Get a loaded texture (or nullptr if not loaded). + SDL_Texture* getTexture(const std::string& path) const; + + // Adopt an externally-created texture so AssetLoader owns its lifetime. + // If a texture is already registered for this path, it will be replaced. + void adoptTexture(const std::string& path, SDL_Texture* texture); + + // Return currently-loading path (empty when idle). + std::string getCurrentLoading() const; + +private: + SDL_Renderer* m_renderer = nullptr; + std::string m_basePath; + + // queued paths (simple FIFO) + std::vector m_queue; + mutable std::mutex m_queueMutex; + + std::unordered_map m_textures; + mutable std::mutex m_texturesMutex; + + std::vector m_errors; + mutable std::mutex m_errorsMutex; + + std::atomic m_totalTasks{0}; + std::atomic m_loadedTasks{0}; + + std::string m_currentLoading; + mutable std::mutex m_currentLoadingMutex; +}; diff --git a/src/app/BackgroundManager.cpp b/src/app/BackgroundManager.cpp new file mode 100644 index 0000000..c043b39 --- /dev/null +++ b/src/app/BackgroundManager.cpp @@ -0,0 +1,165 @@ +#include "app/BackgroundManager.h" +#include +#include +#include +#include +#include +#include "utils/ImagePathResolver.h" + +struct BackgroundManager::Impl { + enum class Phase { Idle, ZoomOut, ZoomIn }; + SDL_Texture* currentTex = nullptr; + SDL_Texture* nextTex = nullptr; + int currentLevel = -1; + int queuedLevel = -1; + float phaseElapsedMs = 0.0f; + float phaseDurationMs = 0.0f; + float fadeDurationMs = 1200.0f; + Phase phase = Phase::Idle; +}; + +static float getPhaseDurationMs(const BackgroundManager::Impl& fader, BackgroundManager::Impl::Phase ph) { + const float total = std::max(1200.0f, fader.fadeDurationMs); + switch (ph) { + case BackgroundManager::Impl::Phase::ZoomOut: return total * 0.45f; + case BackgroundManager::Impl::Phase::ZoomIn: return total * 0.45f; + default: return 0.0f; + } +} + +static void destroyTex(SDL_Texture*& t) { + if (t) { SDL_DestroyTexture(t); t = nullptr; } +} + +BackgroundManager::BackgroundManager() : impl(new Impl()) {} +BackgroundManager::~BackgroundManager() { reset(); delete impl; impl = nullptr; } + +bool BackgroundManager::queueLevelBackground(SDL_Renderer* renderer, int level) { + if (!renderer) return false; + level = std::clamp(level, 0, 32); + if (impl->currentLevel == level || impl->queuedLevel == level) return true; + + char bgPath[256]; + std::snprintf(bgPath, sizeof(bgPath), "assets/images/levels/level%d.jpg", level); + const std::string resolved = AssetPath::resolveImagePath(bgPath); + + SDL_Surface* s = IMG_Load(resolved.c_str()); + if (!s) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Background load failed: %s (%s)", bgPath, resolved.c_str()); + return false; + } + SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer, s); + SDL_DestroySurface(s); + if (!tex) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "CreateTexture failed for %s", resolved.c_str()); + return false; + } + + destroyTex(impl->nextTex); + impl->nextTex = tex; + impl->queuedLevel = level; + + if (!impl->currentTex) { + impl->currentTex = impl->nextTex; + impl->currentLevel = impl->queuedLevel; + impl->nextTex = nullptr; + impl->queuedLevel = -1; + impl->phase = Impl::Phase::Idle; + impl->phaseElapsedMs = 0.0f; + impl->phaseDurationMs = 0.0f; + } else if (impl->phase == Impl::Phase::Idle) { + impl->phase = Impl::Phase::ZoomOut; + impl->phaseDurationMs = getPhaseDurationMs(*impl, impl->phase); + impl->phaseElapsedMs = 0.0f; + } + return true; +} + +void BackgroundManager::update(float frameMs) { + if (impl->phase == Impl::Phase::Idle) return; + if (!impl->currentTex && !impl->nextTex) { impl->phase = Impl::Phase::Idle; return; } + + impl->phaseElapsedMs += frameMs; + if (impl->phaseElapsedMs < std::max(1.0f, impl->phaseDurationMs)) return; + + if (impl->phase == Impl::Phase::ZoomOut) { + if (impl->nextTex) { + destroyTex(impl->currentTex); + impl->currentTex = impl->nextTex; + impl->currentLevel = impl->queuedLevel; + impl->nextTex = nullptr; + impl->queuedLevel = -1; + } + impl->phase = Impl::Phase::ZoomIn; + impl->phaseDurationMs = getPhaseDurationMs(*impl, impl->phase); + impl->phaseElapsedMs = 0.0f; + } else if (impl->phase == Impl::Phase::ZoomIn) { + impl->phase = Impl::Phase::Idle; + impl->phaseElapsedMs = 0.0f; + impl->phaseDurationMs = 0.0f; + } +} + +static void renderDynamic(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float baseScale, float motionClockMs, float alphaMul) { + 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(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f); + SDL_SetTextureAlphaMod(tex, alpha); + SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, ¢er, SDL_FLIP_NONE); + SDL_SetTextureAlphaMod(tex, 255); +} + +void BackgroundManager::render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs) { + if (!renderer) return; + SDL_FRect fullRect{0.f,0.f,(float)winW,(float)winH}; + float duration = std::max(1.0f, impl->phaseDurationMs); + float progress = (impl->phase == Impl::Phase::Idle) ? 0.0f : std::clamp(impl->phaseElapsedMs / duration, 0.0f, 1.0f); + const float seconds = motionClockMs * 0.001f; + + if (impl->phase == Impl::Phase::ZoomOut) { + float scale = 1.0f + progress * 0.15f; + if (impl->currentTex) { + renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f)); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0,0,0, Uint8(progress * 200.0f)); + SDL_RenderFillRect(renderer, &fullRect); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + } + } else if (impl->phase == Impl::Phase::ZoomIn) { + float scale = 1.10f - progress * 0.10f; + Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f); + if (impl->currentTex) { + renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f); + } + } else { + if (impl->currentTex) { + renderDynamic(renderer, impl->currentTex, winW, winH, 1.02f, motionClockMs, 1.0f); + float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f)); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 5,12,28, Uint8(pulse * 90.0f)); + SDL_RenderFillRect(renderer, &fullRect); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + } else if (impl->nextTex) { + renderDynamic(renderer, impl->nextTex, winW, winH, 1.02f, motionClockMs, 1.0f); + } else { + SDL_SetRenderDrawColor(renderer, 0,0,0,255); + SDL_RenderFillRect(renderer, &fullRect); + } + } +} + +void BackgroundManager::reset() { + destroyTex(impl->currentTex); + destroyTex(impl->nextTex); + impl->currentLevel = -1; + impl->queuedLevel = -1; + impl->phaseElapsedMs = 0.0f; + impl->phaseDurationMs = 0.0f; + impl->phase = Impl::Phase::Idle; +} diff --git a/src/app/BackgroundManager.h b/src/app/BackgroundManager.h new file mode 100644 index 0000000..dc53cb8 --- /dev/null +++ b/src/app/BackgroundManager.h @@ -0,0 +1,18 @@ +#pragma once +#include + +class BackgroundManager { +public: + BackgroundManager(); + ~BackgroundManager(); + + bool queueLevelBackground(SDL_Renderer* renderer, int level); + void update(float frameMs); + void render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs); + void reset(); + + struct Impl; + +private: + Impl* impl; +}; diff --git a/src/app/Fireworks.cpp b/src/app/Fireworks.cpp new file mode 100644 index 0000000..8b9b01f --- /dev/null +++ b/src/app/Fireworks.cpp @@ -0,0 +1,147 @@ +#include "app/Fireworks.h" +#include +#include +#include +#include +#include + +namespace { +struct BlockParticle { + float x{}, y{}, vx{}, vy{}, size{}, alpha{}, decay{}, wobblePhase{}, wobbleSpeed{}, coreHeat{}; + BlockParticle(float sx, float sy) : x(sx), y(sy) { + const float spreadDeg = 35.0f; + const float angleDeg = -90.0f + spreadDeg * ((rand() % 200) / 100.0f - 1.0f); + const float angleRad = angleDeg * 3.1415926f / 180.0f; + float speed = 1.3f + (rand() % 220) / 80.0f; + vx = std::cos(angleRad) * speed * 0.55f; + vy = std::sin(angleRad) * speed; + size = 6.0f + (rand() % 40) / 10.0f; + alpha = 1.0f; + decay = 0.0095f + (rand() % 180) / 12000.0f; + wobblePhase = (rand() % 628) / 100.0f; + wobbleSpeed = 0.08f + (rand() % 60) / 600.0f; + coreHeat = 0.65f + (rand() % 35) / 100.0f; + } + bool update() { + vx *= 0.992f; + vy = vy * 0.985f - 0.015f; + x += vx; + y += vy; + wobblePhase += wobbleSpeed; + x += std::sin(wobblePhase) * 0.12f; + alpha -= decay; + size = std::max(1.8f, size - 0.03f); + coreHeat = std::max(0.0f, coreHeat - decay * 0.6f); + return alpha > 0.03f; + } +}; + +struct TetrisFirework { + std::vector particles; + TetrisFirework(float x, float y) { + int particleCount = 30 + rand() % 25; + particles.reserve(particleCount); + for (int i=0;iupdate()) it = particles.erase(it); + else ++it; + } + return !particles.empty(); + } +}; + +static std::vector fireworks; +static double logoAnimCounter = 0.0; +static int hoveredButton = -1; + +static SDL_Color blendFireColor(float heat, float alphaScale, Uint8 minG, Uint8 minB) { + heat = std::clamp(heat, 0.0f, 1.0f); + Uint8 r = 255; + Uint8 g = static_cast(std::clamp(120.0f + heat * (255.0f - 120.0f), float(minG), 255.0f)); + Uint8 b = static_cast(std::clamp(40.0f + (1.0f - heat) * 60.0f, float(minB), 255.0f)); + Uint8 a = static_cast(std::clamp(alphaScale * 255.0f, 0.0f, 255.0f)); + return SDL_Color{r,g,b,a}; +} +} // namespace + +namespace AppFireworks { +void update(double frameMs) { + if (fireworks.size() < 5 && (rand() % 100) < 2) { + float x = 1200.0f * 0.55f + float(rand() % int(1200.0f * 0.35f)); + float y = 1000.0f * 0.80f + float(rand() % int(1000.0f * 0.15f)); + fireworks.emplace_back(x,y); + } + for (auto it = fireworks.begin(); it != fireworks.end();) { + if (!it->update()) it = fireworks.erase(it); + else ++it; + } +} + +void draw(SDL_Renderer* renderer, SDL_Texture*) { + if (!renderer) return; + SDL_BlendMode previousBlend = SDL_BLENDMODE_NONE; + SDL_GetRenderDrawBlendMode(renderer, &previousBlend); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); + static constexpr int quadIdx[6] = {0,1,2,2,1,3}; + + auto makeV = [](float px, float py, SDL_Color c){ + SDL_Vertex v{}; + v.position.x = px; + v.position.y = py; + v.color = SDL_FColor{ c.r/255.0f, c.g/255.0f, c.b/255.0f, c.a/255.0f }; + return v; + }; + + for (auto& f : fireworks) { + for (auto& p : f.particles) { + const float heat = std::clamp(p.alpha * 1.25f + p.coreHeat * 0.5f, 0.0f, 1.0f); + SDL_Color glow = blendFireColor(0.45f + heat * 0.55f, p.alpha * 0.55f, 100, 40); + SDL_Color tailBase = blendFireColor(heat * 0.75f, p.alpha * 0.5f, 70, 25); + SDL_Color tailTip = blendFireColor(heat * 0.35f, p.alpha * 0.2f, 40, 15); + SDL_Color core = blendFireColor(heat, std::min(1.0f, p.alpha * 1.1f), 150, 80); + + float velLen = std::sqrt(p.vx*p.vx + p.vy*p.vy); + SDL_FPoint dir = velLen > 0.001f ? SDL_FPoint{p.vx/velLen,p.vy/velLen} : SDL_FPoint{0.0f,-1.0f}; + SDL_FPoint perp{-dir.y, dir.x}; + const float baseW = std::max(0.8f, p.size * 0.55f); + const float tipW = baseW * 0.35f; + const float tailLen = p.size * (3.0f + (1.0f - p.alpha) * 1.8f); + + SDL_FPoint base{p.x,p.y}; + SDL_FPoint tip{p.x + dir.x*tailLen, p.y + dir.y*tailLen}; + + SDL_Vertex tail[4]; + tail[0] = makeV(base.x + perp.x * baseW, base.y + perp.y * baseW, tailBase); + tail[1] = makeV(base.x - perp.x * baseW, base.y - perp.y * baseW, tailBase); + tail[2] = makeV(tip.x + perp.x * tipW, tip.y + perp.y * tipW, tailTip); + tail[3] = makeV(tip.x - perp.x * tipW, tip.y - perp.y * tipW, tailTip); + SDL_RenderGeometry(renderer, nullptr, tail, 4, quadIdx, 6); + + const float glowAlong = p.size * 0.95f; + const float glowAcross = p.size * 0.6f; + SDL_Vertex glowV[4]; + glowV[0] = makeV(base.x + dir.x * glowAlong, base.y + dir.y * glowAlong, glow); + glowV[1] = makeV(base.x - dir.x * glowAlong, base.y - dir.y * glowAlong, glow); + glowV[2] = makeV(base.x + perp.x * glowAcross, base.y + perp.y * glowAcross, glow); + glowV[3] = makeV(base.x - perp.x * glowAcross, base.y - perp.y * glowAcross, glow); + SDL_RenderGeometry(renderer, nullptr, glowV, 4, quadIdx, 6); + + const float coreW = p.size * 0.35f; + const float coreH = p.size * 0.9f; + SDL_Vertex coreV[4]; + coreV[0] = makeV(base.x + perp.x * coreW, base.y + perp.y * coreW, core); + coreV[1] = makeV(base.x - perp.x * coreW, base.y - perp.y * coreW, core); + coreV[2] = makeV(base.x + dir.x * coreH, base.y + dir.y * coreH, core); + coreV[3] = makeV(base.x - dir.x * coreH, base.y - dir.y * coreH, core); + SDL_RenderGeometry(renderer, nullptr, coreV, 4, quadIdx, 6); + } + } + + SDL_SetRenderDrawBlendMode(renderer, previousBlend); +} + +double getLogoAnimCounter() { return logoAnimCounter; } +int getHoveredButton() { return hoveredButton; } +} // namespace AppFireworks diff --git a/src/app/Fireworks.h b/src/app/Fireworks.h new file mode 100644 index 0000000..fc4ca62 --- /dev/null +++ b/src/app/Fireworks.h @@ -0,0 +1,9 @@ +#pragma once +#include + +namespace AppFireworks { + void draw(SDL_Renderer* renderer, SDL_Texture* tex); + void update(double frameMs); + double getLogoAnimCounter(); + int getHoveredButton(); +} diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp new file mode 100644 index 0000000..689f9d5 --- /dev/null +++ b/src/app/TetrisApp.cpp @@ -0,0 +1,1737 @@ +// TetrisApp.cpp - Main application runtime split out from main.cpp. +// +// This file is intentionally "orchestration-heavy": it wires together SDL, audio, +// asset loading, and the state machine. Keep gameplay mechanics in the gameplay/ +// and states/ modules. + +#include "app/TetrisApp.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/AssetLoader.h" +#include "app/BackgroundManager.h" +#include "app/Fireworks.h" +#include "app/TextureLoader.h" + +#include "audio/Audio.h" +#include "audio/MenuWrappers.h" +#include "audio/SoundEffect.h" + +#include "core/Config.h" +#include "core/Settings.h" +#include "core/state/StateManager.h" + +#include "gameplay/core/Game.h" +#include "gameplay/effects/LineEffect.h" + +#include "graphics/effects/SpaceWarp.h" +#include "graphics/effects/Starfield.h" +#include "graphics/effects/Starfield3D.h" +#include "graphics/renderers/GameRenderer.h" +#include "graphics/renderers/RenderPrimitives.h" +#include "graphics/ui/Font.h" +#include "graphics/ui/HelpOverlay.h" + +#include "persistence/Scores.h" + +#include "states/LevelSelectorState.h" +#include "states/LoadingManager.h" +#include "states/LoadingState.h" +#include "states/MenuState.h" +#include "states/OptionsState.h" +#include "states/PlayingState.h" +#include "states/State.h" + +#include "ui/BottomMenu.h" +#include "../resources/AssetPaths.h" +#include "ui/MenuLayout.h" + +#include "utils/ImagePathResolver.h" + +// ---------- 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" + +static const std::array 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 +}}; + +struct TetrisApp::Impl { + // Global collector for asset loading errors shown on the loading screen + std::vector assetLoadErrors; + std::mutex assetLoadErrorsMutex; + // Loading counters for progress UI and debug overlay + std::atomic totalLoadingTasks{0}; + std::atomic loadedTasks{0}; + std::string currentLoadingFile; + std::mutex currentLoadingMutex; + + // Intro/Menu shared state (wired into StateContext as pointers) + double logoAnimCounter = 0.0; + bool showSettingsPopup = false; + bool showHelpOverlay = false; + bool showExitConfirmPopup = false; + int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO + bool musicEnabled = true; + int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings + bool isNewHighScore = false; + std::string playerName; + bool helpOverlayPausedGame = false; + + SDL_Window* window = nullptr; + SDL_Renderer* renderer = nullptr; + + AssetLoader assetLoader; + std::unique_ptr loadingManager; + std::unique_ptr textureLoader; + + FontAtlas pixelFont; + FontAtlas font; + + ScoreManager scores; + std::atomic scoresLoadComplete{false}; + std::jthread scoreLoader; + std::jthread menuTrackLoader; + + Starfield starfield; + Starfield3D starfield3D; + SpaceWarp spaceWarp; + SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward; + bool warpAutoPilotEnabled = true; + + LineEffect lineEffect; + + SDL_Texture* logoTex = nullptr; + SDL_Texture* logoSmallTex = nullptr; + int logoSmallW = 0; + int logoSmallH = 0; + SDL_Texture* backgroundTex = nullptr; + SDL_Texture* mainScreenTex = nullptr; + int mainScreenW = 0; + int mainScreenH = 0; + + SDL_Texture* blocksTex = nullptr; + SDL_Texture* scorePanelTex = nullptr; + SDL_Texture* statisticsPanelTex = nullptr; + SDL_Texture* nextPanelTex = nullptr; + SDL_Texture* holdPanelTex = nullptr; + + BackgroundManager levelBackgrounds; + int startLevelSelection = 0; + + // 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 loadingStarted{false}; + std::atomic_bool loadingComplete{false}; + std::atomic loadingStep{0}; + + std::unique_ptr game; + std::vector singleSounds; + std::vector doubleSounds; + std::vector tripleSounds; + std::vector tetrisSounds; + bool suppressLineVoiceForLevelUp = false; + + AppState state = AppState::Loading; + double loadingProgress = 0.0; + Uint64 loadStart = 0; + bool running = true; + bool isFullscreen = false; + bool leftHeld = false; + bool rightHeld = false; + double moveTimerMs = 0.0; + double DAS = 170.0; + double ARR = 40.0; + SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; + float logicalScale = 1.f; + Uint64 lastMs = 0; + + enum class MenuFadePhase { None, FadeOut, FadeIn }; + MenuFadePhase menuFadePhase = MenuFadePhase::None; + double menuFadeClockMs = 0.0; + float menuFadeAlpha = 0.0f; + 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; + double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; + std::array GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; + double gameplayBackgroundClockMs = 0.0; + + std::unique_ptr stateMgr; + StateContext ctx{}; + std::unique_ptr loadingState; + std::unique_ptr menuState; + std::unique_ptr optionsState; + std::unique_ptr levelSelectorState; + std::unique_ptr playingState; + + int init(); + void runLoop(); + void shutdown(); +}; + +TetrisApp::TetrisApp() + : impl_(std::make_unique()) +{ +} + +TetrisApp::~TetrisApp() = default; + +int TetrisApp::run() +{ + const int initRc = impl_->init(); + if (initRc != 0) { + impl_->shutdown(); + return initRc; + } + + impl_->runLoop(); + impl_->shutdown(); + return 0; +} + +int TetrisApp::Impl::init() +{ + // Initialize random seed for procedural effects + srand(static_cast(SDL_GetTicks())); + + // Load settings + Settings::instance().load(); + + // Sync shared 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; + } + + window = SDL_CreateWindow("SpaceTris (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; + } + renderer = SDL_CreateRenderer(window, nullptr); + if (!renderer) + { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError()); + SDL_DestroyWindow(window); + window = nullptr; + 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.init(renderer); + loadingManager = std::make_unique(&assetLoader); + + // Legacy image loader (used only as a fallback when AssetLoader misses) + textureLoader = std::make_unique( + loadedTasks, + currentLoadingFile, + currentLoadingMutex, + assetLoadErrors, + assetLoadErrorsMutex); + + // Load scores asynchronously but keep the worker alive until shutdown + scoreLoader = std::jthread([this]() { + scores.load(); + scoresLoadComplete.store(true, std::memory_order_release); + }); + + starfield.init(200, LOGICAL_W, LOGICAL_H); + starfield3D.init(LOGICAL_W, LOGICAL_H, 200); + spaceWarp.init(LOGICAL_W, LOGICAL_H, 420); + spaceWarp.setFlightMode(warpFlightMode); + warpAutoPilotEnabled = true; + spaceWarp.setAutoPilotEnabled(true); + + // Initialize line clearing effects + lineEffect.init(renderer); + + game = std::make_unique(startLevelSelection); + game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); + game->reset(startLevelSelection); + + // Define voice line banks for gameplay callbacks + singleSounds = {"well_played", "smooth_clear", "great_move"}; + doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; + tripleSounds = {"impressive", "triple_strike"}; + tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"}; + suppressLineVoiceForLevelUp = false; + + auto playVoiceCue = [this](int linesCleared) { + const std::vector* 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); + } + }; + + game->setSoundCallback([this, playVoiceCue](int linesCleared) { + if (linesCleared <= 0) { + return; + } + + SoundEffectManager::instance().playSound("clear_line", 1.0f); + + if (!suppressLineVoiceForLevelUp) { + playVoiceCue(linesCleared); + } + suppressLineVoiceForLevelUp = false; + }); + + game->setLevelUpCallback([this](int /*newLevel*/) { + SoundEffectManager::instance().playSound("new_level", 1.0f); + SoundEffectManager::instance().playSound("lets_go", 1.0f); + suppressLineVoiceForLevelUp = true; + }); + + state = AppState::Loading; + loadingProgress = 0.0; + loadStart = SDL_GetTicks(); + running = true; + isFullscreen = Settings::instance().isFullscreen(); + leftHeld = false; + rightHeld = false; + moveTimerMs = 0; + DAS = 170.0; + ARR = 40.0; + logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H}; + logicalScale = 1.f; + lastMs = SDL_GetPerformanceCounter(); + + menuFadePhase = MenuFadePhase::None; + menuFadeClockMs = 0.0; + menuFadeAlpha = 0.0f; + MENU_PLAY_FADE_DURATION_MS = 450.0; + menuFadeTarget = AppState::Menu; + menuPlayCountdownArmed = false; + gameplayCountdownActive = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; + GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; + gameplayBackgroundClockMs = 0.0; + + // Instantiate state manager + stateMgr = std::make_unique(state); + + // Prepare shared context for states + ctx = StateContext{}; + ctx.stateManager = stateMgr.get(); + ctx.game = game.get(); + ctx.scores = nullptr; + 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 = [this](bool enable) { + SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0); + isFullscreen = enable; + }; + ctx.queryFullscreen = [this]() -> bool { + return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; + }; + ctx.requestQuit = [this]() { + running = false; + }; + + auto beginStateFade = [this](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) { + if (game) { + game->setPaused(false); + } + } + }; + + auto startMenuPlayTransition = [this, beginStateFade]() { + 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 = [this, startMenuPlayTransition, beginStateFade](AppState targetState) { + if (!ctx.stateManager) { + return; + } + if (targetState == AppState::Playing) { + startMenuPlayTransition(); + return; + } + beginStateFade(targetState, false); + }; + ctx.requestFadeTransition = requestStateFade; + + loadingState = std::make_unique(ctx); + menuState = std::make_unique(ctx); + optionsState = std::make_unique(ctx); + levelSelectorState = std::make_unique(ctx); + playingState = std::make_unique(ctx); + + stateMgr->registerHandler(AppState::Loading, [this](const SDL_Event& e){ loadingState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); + stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); }); + + stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); + stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); + + stateMgr->registerHandler(AppState::Options, [this](const SDL_Event& e){ optionsState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Options, [this](){ optionsState->onEnter(); }); + stateMgr->registerOnExit(AppState::Options, [this](){ optionsState->onExit(); }); + + stateMgr->registerHandler(AppState::LevelSelector, [this](const SDL_Event& e){ levelSelectorState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::LevelSelector, [this](){ levelSelectorState->onEnter(); }); + stateMgr->registerOnExit(AppState::LevelSelector, [this](){ levelSelectorState->onExit(); }); + + stateMgr->registerHandler(AppState::Playing, [this](const SDL_Event& e){ playingState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Playing, [this](){ playingState->onEnter(); }); + stateMgr->registerOnExit(AppState::Playing, [this](){ playingState->onExit(); }); + + loadingState->onEnter(); + loadingStarted.store(true); + + return 0; +} + +void TetrisApp::Impl::runLoop() +{ + auto ensureScoresLoaded = [this]() { + if (scoreLoader.joinable()) { + scoreLoader.join(); + } + if (!ctx.scores) { + ctx.scores = &scores; + } + }; + + auto startMenuPlayTransition = [this]() { + if (ctx.startPlayTransition) { + ctx.startPlayTransition(); + } + }; + + auto requestStateFade = [this](AppState targetState) { + if (ctx.requestFadeTransition) { + ctx.requestFadeTransition(targetState); + } + }; + + while (running) + { + if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) { + ensureScoresLoaded(); + } + + int winW = 0, winH = 0; + SDL_GetWindowSize(window, &winW, &winH); + + logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H); + if (logicalScale <= 0) + logicalScale = 1.f; + + logicalVP.w = winW; + logicalVP.h = winH; + logicalVP.x = 0; + logicalVP.y = 0; + + SDL_Event e; + while (SDL_PollEvent(&e)) + { + if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) + running = false; + else { + 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); + state = stateMgr->getState(); + } + + 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) + { + SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled()); + Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); + } + const bool helpToggleKey = + (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu); + if (helpToggleKey) + { + 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 (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) { + showHelpOverlay = false; + if (state == AppState::Playing && helpOverlayPausedGame) { + game->setPaused(false); + } + helpOverlayPausedGame = false; + } + 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"); + } + } + + 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) { + game->reset(startLevelSelection); + state = AppState::Playing; + stateMgr->setState(state); + } else if (e.key.scancode == SDL_SCANCODE_ESCAPE) { + state = AppState::Menu; + stateMgr->setState(state); + } + } + } + + 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) + { + if (showSettingsPopup) { + showSettingsPopup = false; + } else { + ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; + + auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); + hoveredButton = menuInput.hoveredIndex; + + if (menuInput.activated) { + switch (*menuInput.activated) { + case ui::BottomMenuItem::Play: + startMenuPlayTransition(); + break; + case ui::BottomMenuItem::Level: + requestStateFade(AppState::LevelSelector); + break; + case ui::BottomMenuItem::Options: + requestStateFade(AppState::Options); + break; + case ui::BottomMenuItem::Help: + if (menuState) menuState->showHelpPanel(true); + break; + case ui::BottomMenuItem::About: + if (menuState) menuState->showAboutPanel(true); + break; + case ui::BottomMenuItem::Exit: + showExitConfirmPopup = true; + exitPopupSelectedButton = 1; + break; + } + } + + 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) { + float lx2 = (mx - logicalVP.x) / logicalScale; + float ly2 = (my - logicalVP.y) / logicalScale; + 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 localX = lx2 - contentOffsetX; + float localY = ly2 - contentOffsetY; + + float popupW = 400, popupH = 200; + float popupX = (LOGICAL_W - popupW) / 2.0f; + float popupY = (LOGICAL_H - popupH) / 2.0f; + 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) { + if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) { + showExitConfirmPopup = false; + game->reset(startLevelSelection); + state = AppState::Menu; + stateMgr->setState(state); + } else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) { + showExitConfirmPopup = false; + game->setPaused(false); + } + } else { + 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) + { + ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; + auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); + hoveredButton = menuInput.hoveredIndex; + } + } + } + } + } + + Uint64 now = SDL_GetPerformanceCounter(); + double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); + lastMs = now; + 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]; + + if (state == AppState::Playing) + game->setSoftDropping(down && !game->isPaused()); + else + game->setSoftDropping(false); + + 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 (musicLoadingStarted && !musicLoaded) { + currentTrackLoading = Audio::instance().getLoadedTrackCount(); + if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) { + Audio::instance().shuffle(); + musicLoaded = true; + } + } + + if (state == AppState::Playing) + { + if (!game->isPaused()) { + game->tickGravity(frameMs); + game->updateElapsedTime(); + + if (lineEffect.isActive()) { + if (lineEffect.update(frameMs / 1000.0f)) { + game->clearCompletedLines(); + } + } + } + if (game->isGameOver()) + { + if (game->score() > 0) { + isNewHighScore = true; + playerName.clear(); + 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; + if (loadingStarted.load() && !loadingComplete.load()) { + static bool queuedTextures = false; + static std::vector queuedPaths; + if (!queuedTextures) { + queuedTextures = true; + constexpr int baseTasks = 25; + totalLoadingTasks.store(baseTasks); + loadedTasks.store(0); + { + std::lock_guard lk(assetLoadErrorsMutex); + assetLoadErrors.clear(); + } + { + std::lock_guard lk(currentLoadingMutex); + currentLoadingFile.clear(); + } + + 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++; + } + totalLoadingTasks.store(baseTasks + totalTracks); + if (totalTracks > 0) { + Audio::instance().startBackgroundLoading(); + musicLoadingStarted = true; + } else { + musicLoaded = true; + } + + pixelFont.init(AssetPath::resolveWithBase(Assets::FONT_ORBITRON), 22); + loadedTasks.fetch_add(1); + font.init(AssetPath::resolveWithBase(Assets::FONT_EXO2), 20); + loadedTasks.fetch_add(1); + + queuedPaths = { + Assets::LOGO, + Assets::LOGO, + Assets::MAIN_SCREEN, + Assets::BLOCKS_SPRITE, + Assets::PANEL_SCORE, + Assets::PANEL_STATS, + Assets::NEXT_PANEL, + Assets::HOLD_PANEL + }; + for (auto &p : queuedPaths) { + loadingManager->queueTexture(p); + } + queuedTextureCount = static_cast(queuedPaths.size()); + + SoundEffectManager::instance().init(); + loadedTasks.fetch_add(1); + + const std::vector 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 lk(currentLoadingMutex); + currentLoadingFile = basePath; + } + std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" }); + if (!resolved.empty()) { + SoundEffectManager::instance().loadSound(id, resolved); + } + loadedTasks.fetch_add(1); + { + std::lock_guard lk(currentLoadingMutex); + currentLoadingFile.clear(); + } + } + } + + bool texturesDone = loadingManager->update(); + if (texturesDone) { + logoTex = assetLoader.getTexture(Assets::LOGO); + logoSmallTex = assetLoader.getTexture(Assets::LOGO); + mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); + blocksTex = assetLoader.getTexture(Assets::BLOCKS_SPRITE); + scorePanelTex = assetLoader.getTexture(Assets::PANEL_SCORE); + statisticsPanelTex = assetLoader.getTexture(Assets::PANEL_STATS); + nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL); + holdPanelTex = assetLoader.getTexture(Assets::HOLD_PANEL); + + 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(std::lround(w)); + outH = static_cast(std::lround(h)); + } + }; + + ensureTextureSize(logoSmallTex, logoSmallW, logoSmallH); + ensureTextureSize(mainScreenTex, mainScreenW, mainScreenH); + + auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) { + if (!outTex) { + SDL_Texture* loaded = textureLoader->loadFromImage(renderer, p, outW, outH); + if (loaded) { + outTex = loaded; + assetLoader.adoptTexture(p, loaded); + } + } + }; + + legacyLoad(Assets::LOGO, logoTex); + legacyLoad(Assets::LOGO, logoSmallTex, &logoSmallW, &logoSmallH); + legacyLoad(Assets::MAIN_SCREEN, mainScreenTex, &mainScreenW, &mainScreenH); + legacyLoad(Assets::BLOCKS_SPRITE, blocksTex); + legacyLoad(Assets::PANEL_SCORE, scorePanelTex); + legacyLoad(Assets::PANEL_STATS, statisticsPanelTex); + legacyLoad(Assets::NEXT_PANEL, nextPanelTex); + legacyLoad(Assets::HOLD_PANEL, holdPanelTex); + + 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); + + // Ensure the generated fallback texture is cleaned up with other assets. + assetLoader.adoptTexture(Assets::BLOCKS_SPRITE, blocksTex); + } + + if (musicLoaded) { + loadingComplete.store(true); + } + } + } + + const int totalTasks = totalLoadingTasks.load(std::memory_order_acquire); + const int musicDone = std::min(totalTracks, currentTrackLoading); + int doneTasks = loadedTasks.load(std::memory_order_acquire) + musicDone; + if (queuedTextureCount > 0) { + float texProg = loadingManager->getProgress(); + int texDone = static_cast(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 { + 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(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) + { + 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; + } + + if (state == AppState::Menu) { + Audio::instance().playMenuMusic(); + } else { + Audio::instance().playGameMusic(); + } + musicStarted = true; + } + } + + static AppState previousState = AppState::Loading; + if (state != previousState && musicStarted) { + if (state == AppState::Menu && previousState == AppState::Playing) { + Audio::instance().playMenuMusic(); + } else if (state == AppState::Playing && previousState == AppState::Menu) { + Audio::instance().playGameMusic(); + } + } + previousState = state; + + 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)); + } + + levelBackgrounds.update(float(frameMs)); + + if (state == AppState::Menu) { + logoAnimCounter += frameMs * 0.0008; + } + + 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; + } + + 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.holdPanelTex = holdPanelTex; + 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(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); + } + + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_RenderClear(renderer); + + if (state == AppState::Playing) { + int bgLevel = std::clamp(game->level(), 0, 32); + levelBackgrounds.queueLevelBackground(renderer, bgLevel); + levelBackgrounds.render(renderer, winW, winH, static_cast(gameplayBackgroundClockMs)); + } else if (state == AppState::Loading) { + starfield3D.draw(renderer); + } else if (state == AppState::Menu) { + spaceWarp.draw(renderer, 1.0f); + } else if (state == AppState::LevelSelector || state == AppState::Options) { + // No background texture + } else { + starfield.draw(renderer); + } + + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + + switch (state) + { + case AppState::Loading: + { + 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) + { RenderPrimitives::fillRect(renderer, x + contentOffsetX, y + contentOffsetY, w, h, c); }; + + const bool isLimitedHeight = LOGICAL_H < 450; + const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0; + const float loadingTextHeight = 20; + const float barHeight = 20; + const float barPaddingVertical = isLimitedHeight ? 15 : 35; + const float percentTextHeight = 24; + const float spacingBetweenElements = isLimitedHeight ? 5 : 15; + + const float totalContentHeight = logoHeight + + (logoHeight > 0 ? spacingBetweenElements : 0) + + loadingTextHeight + + barPaddingVertical + + barHeight + + spacingBetweenElements + + percentTextHeight; + + float currentY = (LOGICAL_H - totalContentHeight) / 2.0f; + + if (logoTex) + { + const int lw = 872, lh = 273; + 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(lw); + const float scaleFactorHeight = availableHeight / static_cast(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; + } + + const char* loadingText = "LOADING"; + float textWidth = strlen(loadingText) * 12.0f; + float textX = (LOGICAL_W - textWidth) / 2.0f; + pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255}); + + currentY += loadingTextHeight + barPaddingVertical; + + const int barW = 400, barH = 20; + const int bx = (LOGICAL_W - barW) / 2; + + drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255}); + drawRect(bx, currentY, barW, barH, {34, 34, 34, 255}); + drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255}); + + currentY += barH + spacingBetweenElements; + + int percentage = int(loadingProgress * 100); + char percentText[16]; + std::snprintf(percentText, sizeof(percentText), "%d%%", percentage); + + float percentWidth = strlen(percentText) * 12.0f; + float percentX = (LOGICAL_W - percentWidth) / 2.0f; + pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255}); + + { + std::lock_guard lk(assetLoadErrorsMutex); + const int maxShow = 5; + int count = static_cast(assetLoadErrors.size()); + if (count > 0) { + int start = std::max(0, count - maxShow); + float errY = currentY + spacingBetweenElements + 8.0f; + + std::string latest = assetLoadErrors.back(); + std::string shortTitle = "SpaceTris - 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()); + + FILE* tf = fopen("tetris_trace.log", "a"); + if (tf) { + fprintf(tf, "Loading error: %s\n", assetLoadErrors.back().c_str()); + fclose(tf); + } + + for (int i = start; i < count; ++i) { + const std::string& msg = assetLoadErrors[i]; + 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; + } + } + } + + if (Settings::instance().isDebugEnabled()) { + std::string cur; + { + std::lock_guard lk(currentLoadingMutex); + cur = currentLoadingFile; + } + char buf[128]; + int loaded = loadedTasks.load(); + int total = 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: + if (!mainScreenTex) { + mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); + } + if (!mainScreenTex) { + SDL_Texture* loaded = textureLoader->loadFromImage(renderer, Assets::MAIN_SCREEN, &mainScreenW, &mainScreenH); + if (loaded) { + assetLoader.adoptTexture(Assets::MAIN_SCREEN, loaded); + mainScreenTex = loaded; + } + } + if (menuState) { + menuState->drawMainButtonNormally = false; + menuState->render(renderer, logicalScale, logicalVP); + } + if (mainScreenTex) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.f, 1.f); + float texW = mainScreenW > 0 ? static_cast(mainScreenW) : 0.0f; + float texH = mainScreenH > 0 ? static_cast(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(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_SetTextureScaleMode(mainScreenTex, SDL_SCALEMODE_LINEAR); + SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst); + } + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + } + if (menuState) { + menuState->renderMainButtonTop(renderer, logicalScale, logicalVP); + } + break; + case AppState::Options: + optionsState->render(renderer, logicalScale, logicalVP); + break; + case AppState::LevelSelector: + 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: + GameRenderer::renderPlayingState( + renderer, + game.get(), + &pixelFont, + &lineEffect, + blocksTex, + ctx.statisticsPanelTex, + scorePanelTex, + nextPanelTex, + holdPanelTex, + (float)LOGICAL_W, + (float)LOGICAL_H, + logicalScale, + (float)winW, + (float)winH + ); + + { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); + SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &fullWin); + + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + + 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; + + float boxW = 500.0f; + float boxH = 350.0f; + float boxX = (LOGICAL_W - boxW) * 0.5f; + float boxY = (LOGICAL_H - boxH) * 0.5f; + + SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); + SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH}; + SDL_RenderFillRect(renderer, &boxRect); + + SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255); + SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6}; + SDL_RenderFillRect(renderer, &borderRect); + SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); + SDL_RenderFillRect(renderer, &boxRect); + + 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}); + + 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) { + 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}); + + 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); + + 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; + + int nameW = 0, nameH = 0; + if (!playerName.empty()) { + pixelFont.measure(playerName, nameScale, nameW, nameH); + } else { + nameH = metricsH; + } + + float textX = inputX + (inputW - static_cast(nameW)) * 0.5f + contentOffsetX; + float textY = inputY + (inputH - static_cast(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(cursorW)) * 0.5f + contentOffsetX + : textX + static_cast(nameW); + float cursorY = inputY + (inputH - static_cast(cursorH)) * 0.5f + contentOffsetY; + pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255}); + } + + 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 { + 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}); + + 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}); + + 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) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.f, 1.f); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + int cappedIndex = std::min(gameplayCountdownIndex, static_cast(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); + const char* label = GAMEPLAY_COUNTDOWN_LABELS[cappedIndex]; + bool isFinalCue = (cappedIndex == static_cast(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); + float textScale = isFinalCue ? 4.5f : 5.0f; + int textW = 0, textH = 0; + pixelFont.measure(label, textScale, textW, textH); + + float textX = (winW - static_cast(textW)) * 0.5f; + float textY = (winH - static_cast(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); + } +} + +void TetrisApp::Impl::shutdown() +{ + Settings::instance().save(); + + // BackgroundManager owns its own textures. + levelBackgrounds.reset(); + + // All textures are owned by AssetLoader (including legacy fallbacks adopted above). + logoTex = nullptr; + logoSmallTex = nullptr; + backgroundTex = nullptr; + mainScreenTex = nullptr; + blocksTex = nullptr; + scorePanelTex = nullptr; + statisticsPanelTex = nullptr; + nextPanelTex = nullptr; + + if (scoreLoader.joinable()) { + scoreLoader.join(); + if (!ctx.scores) { + ctx.scores = &scores; + } + } + if (menuTrackLoader.joinable()) { + menuTrackLoader.join(); + } + + lineEffect.shutdown(); + Audio::instance().shutdown(); + SoundEffectManager::instance().shutdown(); + + // Destroy textures before tearing down the renderer/window. + assetLoader.shutdown(); + + pixelFont.shutdown(); + font.shutdown(); + + TTF_Quit(); + + if (renderer) { + SDL_DestroyRenderer(renderer); + renderer = nullptr; + } + if (window) { + SDL_DestroyWindow(window); + window = nullptr; + } + SDL_Quit(); +} diff --git a/src/app/TetrisApp.h b/src/app/TetrisApp.h new file mode 100644 index 0000000..1b795d0 --- /dev/null +++ b/src/app/TetrisApp.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// TetrisApp is the top-level application orchestrator. +// +// Responsibilities: +// - SDL/TTF init + shutdown +// - Asset/music loading + loading screen +// - Main loop + state transitions +// +// It uses a PIMPL to keep `TetrisApp.h` light (faster builds) and to avoid leaking +// SDL-heavy includes into every translation unit. +class TetrisApp { +public: + TetrisApp(); + ~TetrisApp(); + + TetrisApp(const TetrisApp&) = delete; + TetrisApp& operator=(const TetrisApp&) = delete; + + // Runs the application until exit is requested. + // Returns a non-zero exit code on initialization failure. + int run(); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/src/app/TextureLoader.cpp b/src/app/TextureLoader.cpp new file mode 100644 index 0000000..ef9af8e --- /dev/null +++ b/src/app/TextureLoader.cpp @@ -0,0 +1,91 @@ +#include "app/TextureLoader.h" + +#include + +#include +#include +#include + +#include "utils/ImagePathResolver.h" + +TextureLoader::TextureLoader( + std::atomic& loadedTasks, + std::string& currentLoadingFile, + std::mutex& currentLoadingMutex, + std::vector& assetLoadErrors, + std::mutex& assetLoadErrorsMutex) + : loadedTasks_(loadedTasks) + , currentLoadingFile_(currentLoadingFile) + , currentLoadingMutex_(currentLoadingMutex) + , assetLoadErrors_(assetLoadErrors) + , assetLoadErrorsMutex_(assetLoadErrorsMutex) +{ +} + +void TextureLoader::setCurrentLoadingFile(const std::string& filename) { + std::lock_guard lk(currentLoadingMutex_); + currentLoadingFile_ = filename; +} + +void TextureLoader::clearCurrentLoadingFile() { + std::lock_guard lk(currentLoadingMutex_); + currentLoadingFile_.clear(); +} + +void TextureLoader::recordAssetLoadError(const std::string& message) { + std::lock_guard lk(assetLoadErrorsMutex_); + assetLoadErrors_.emplace_back(message); +} + +SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW, int* outH) { + if (!renderer) { + return nullptr; + } + + const std::string resolvedPath = AssetPath::resolveImagePath(path); + setCurrentLoadingFile(resolvedPath.empty() ? path : resolvedPath); + + SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); + if (!surface) { + { + std::ostringstream ss; + ss << "Image load failed: " << path << " (" << resolvedPath << "): " << SDL_GetError(); + recordAssetLoadError(ss.str()); + } + loadedTasks_.fetch_add(1); + clearCurrentLoadingFile(); + 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::ostringstream ss; + ss << "Texture create failed: " << resolvedPath << ": " << SDL_GetError(); + recordAssetLoadError(ss.str()); + } + loadedTasks_.fetch_add(1); + clearCurrentLoadingFile(); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + loadedTasks_.fetch_add(1); + clearCurrentLoadingFile(); + + if (resolvedPath != path) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str()); + } + + return texture; +} diff --git a/src/app/TextureLoader.h b/src/app/TextureLoader.h new file mode 100644 index 0000000..d807fe7 --- /dev/null +++ b/src/app/TextureLoader.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include +#include +#include + +class TextureLoader { +public: + TextureLoader( + std::atomic& loadedTasks, + std::string& currentLoadingFile, + std::mutex& currentLoadingMutex, + std::vector& assetLoadErrors, + std::mutex& assetLoadErrorsMutex); + + SDL_Texture* loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr); + +private: + std::atomic& loadedTasks_; + std::string& currentLoadingFile_; + std::mutex& currentLoadingMutex_; + std::vector& assetLoadErrors_; + std::mutex& assetLoadErrorsMutex_; + + void setCurrentLoadingFile(const std::string& filename); + void clearCurrentLoadingFile(); + void recordAssetLoadError(const std::string& message); +}; diff --git a/src/audio/Audio.cpp b/src/audio/Audio.cpp index d547585..582f7b5 100644 --- a/src/audio/Audio.cpp +++ b/src/audio/Audio.cpp @@ -137,6 +137,11 @@ void Audio::shuffle(){ bool Audio::ensureStream(){ if(audioStream) return true; + // Ensure audio spec is initialized + if (!init()) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to initialize audio spec before opening device stream"); + return false; + } audioStream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &outSpec, &Audio::streamCallback, this); if(!audioStream){ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] SDL_OpenAudioDeviceStream failed: %s", SDL_GetError()); diff --git a/src/core/Config.h b/src/core/Config.h index 500c4ca..9d8ea3d 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -14,7 +14,7 @@ namespace Config { namespace Window { constexpr int DEFAULT_WIDTH = 1200; constexpr int DEFAULT_HEIGHT = 1000; - constexpr const char* DEFAULT_TITLE = "Tetris (SDL3)"; + constexpr const char* DEFAULT_TITLE = "SpaceTris (SDL3)"; constexpr bool DEFAULT_VSYNC = true; } @@ -130,7 +130,7 @@ namespace Config { constexpr const char* LOGO_BMP = "assets/images/logo.bmp"; constexpr const char* LOGO_SMALL_BMP = "assets/images/logo_small.bmp"; constexpr const char* BACKGROUND_BMP = "assets/images/main_background.bmp"; - constexpr const char* BLOCKS_BMP = "assets/images/blocks90px_001.bmp"; + constexpr const char* BLOCKS_BMP = "assets/images/2.png"; } // Audio settings diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 30d8bc8..bd22541 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -328,6 +328,26 @@ bool ApplicationManager::initializeManagers() { // Global hotkeys (handled across all states) if (pressed) { + // While the help overlay is visible, swallow input so gameplay/menu doesn't react. + // Allow only help-toggle/close keys to pass through this global handler. + if (m_showHelpOverlay) { + if (sc == SDL_SCANCODE_ESCAPE) { + m_showHelpOverlay = false; + if (m_helpOverlayPausedGame && m_game) { + m_game->setPaused(false); + } + m_helpOverlayPausedGame = false; + } else if (sc == SDL_SCANCODE_F1) { + // Toggle off + m_showHelpOverlay = false; + if (m_helpOverlayPausedGame && m_game) { + m_game->setPaused(false); + } + m_helpOverlayPausedGame = false; + } + consume = true; + } + // Toggle fullscreen on F, F11 or Alt+Enter (or Alt+KP_Enter) if (sc == SDL_SCANCODE_F || sc == SDL_SCANCODE_F11 || ((sc == SDL_SCANCODE_RETURN || sc == SDL_SCANCODE_RETURN2 || sc == SDL_SCANCODE_KP_ENTER) && @@ -362,8 +382,9 @@ bool ApplicationManager::initializeManagers() { consume = true; } - if (!consume && sc == SDL_SCANCODE_H) { + if (!consume && (sc == SDL_SCANCODE_F1)) { AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading; + // F1 is global (except Loading). if (currentState != AppState::Loading) { m_showHelpOverlay = !m_showHelpOverlay; if (currentState == AppState::Playing && m_game) { @@ -1144,6 +1165,7 @@ void ApplicationManager::setupStateHandlers() { m_stateContext.statisticsPanelTex, m_stateContext.scorePanelTex, m_stateContext.nextPanelTex, + m_stateContext.holdPanelTex, LOGICAL_W, LOGICAL_H, logicalScale, diff --git a/src/graphics/GameRenderer.cpp b/src/graphics/GameRenderer.cpp index 4a5f339..fcac0a9 100644 --- a/src/graphics/GameRenderer.cpp +++ b/src/graphics/GameRenderer.cpp @@ -116,6 +116,169 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex } } +// Draw the hold panel (extracted for readability). +static void drawHoldPanel(SDL_Renderer* renderer, + Game* game, + FontAtlas* pixelFont, + SDL_Texture* blocksTex, + SDL_Texture* holdPanelTex, + float scoreX, + float statsW, + float gridY, + float finalBlockSize, + float statsY, + float statsH) { + float holdBlockH = (finalBlockSize * 0.6f) * 4.0f; + // Base panel height; enforce minimum but allow larger to fit texture + float panelH = std::max(holdBlockH + 12.0f, 420.0f); + // Increase height by ~20% of the hold block to give more vertical room + float extraH = holdBlockH * 0.20f; + panelH += extraH; + + const float holdGap = 18.0f; + + // Align X to the bottom score label (`scoreX`) plus an offset to the right + float panelX = scoreX + 30.0f; // move ~30px right to align with score label + float panelW = statsW + 32.0f; + float panelY = gridY - panelH - holdGap; + // Move panel a bit higher for spacing (about half the extra height) + panelY -= extraH * 0.5f; + float labelX = panelX + 40.0f; // shift HOLD label ~30px to the right + float labelY = panelY + 8.0f; + + if (holdPanelTex) { + int texW = 0, texH = 0; + SDL_QueryTexture(holdPanelTex, nullptr, nullptr, &texW, &texH); + if (texW > 0 && texH > 0) { + // Fill panel width and compute destination height from texture aspect ratio + float texAspect = float(texH) / float(texW); + float dstW = panelW; + float dstH = dstW * texAspect; + // If texture height exceeds panel, expand panelH to fit texture comfortably + if (dstH + 12.0f > panelH) { + panelH = dstH + 12.0f; + panelY = gridY - panelH - holdGap; + labelY = panelY + 8.0f; + } + float dstX = panelX; + float dstY = panelY + (panelH - dstH) * 0.5f; + + SDL_FRect panelDst{dstX, dstY, dstW, dstH}; + SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND); + SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR); + SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst); + } else { + // Fallback to filling panel area if texture metrics unavailable + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); + SDL_FRect panelDst{panelX, panelY, panelW, panelH}; + SDL_RenderFillRect(renderer, &panelDst); + } + } else { + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); + SDL_FRect panelDst{panelX, panelY, panelW, panelH}; + SDL_RenderFillRect(renderer, &panelDst); + } + + pixelFont->draw(renderer, labelX, labelY, "HOLD", 1.0f, {255, 220, 0, 255}); + + if (game->held().type < PIECE_COUNT) { + float previewW = finalBlockSize * 0.6f * 4.0f; + float previewX = panelX + (panelW - previewW) * 0.5f; + float previewY = panelY + (panelH - holdBlockH) * 0.5f; + drawSmallPiece(renderer, blocksTex, static_cast(game->held().type), previewX, previewY, finalBlockSize * 0.6f); + } +} + +// Draw next piece panel (border/texture + preview) +static void drawNextPanel(SDL_Renderer* renderer, + FontAtlas* pixelFont, + SDL_Texture* nextPanelTex, + SDL_Texture* blocksTex, + Game* game, + float nextX, + float nextY, + float nextW, + float nextH, + float contentOffsetX, + float contentOffsetY, + float finalBlockSize) { + if (nextPanelTex) { + SDL_FRect dst{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH }; + SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND); + SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst); + } else { + // Draw bordered panel as before + SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255); + SDL_FRect outer{ nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6 }; + SDL_RenderFillRect(renderer, &outer); + SDL_SetRenderDrawColor(renderer, 30, 35, 50, 255); + SDL_FRect inner{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH }; + SDL_RenderFillRect(renderer, &inner); + } + + // Label and small preview + pixelFont->draw(renderer, nextX + 10, nextY - 20, "NEXT", 1.0f, {255, 220, 0, 255}); + if (game->next().type < PIECE_COUNT) { + drawSmallPiece(renderer, blocksTex, static_cast(game->next().type), nextX + 10, nextY + 5, finalBlockSize * 0.6f); + } +} + +// Draw score panel (right side) +static void drawScorePanel(SDL_Renderer* renderer, + FontAtlas* pixelFont, + Game* game, + float scoreX, + float gridY, + float GRID_H, + float finalBlockSize) { + const float contentTopOffset = 0.0f; + const float contentBottomOffset = 290.0f; + const float contentPad = 36.0f; + float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad; + float baseY = gridY + (GRID_H - scoreContentH) * 0.5f; + + 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 + int startLv = game->startLevelBase(); + int firstThreshold = (startLv + 1) * 10; + int linesDone = game->lines(); + int nextThreshold = 0; + if (linesDone < firstThreshold) { + nextThreshold = firstThreshold; + } else { + int blocksPast = linesDone - firstThreshold; + nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10; + } + int linesForNext = std::max(0, nextThreshold - linesDone); + 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 display + pixelFont->draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255}); + int totalSecs = static_cast(game->elapsed()); + int mins = totalSecs / 60; + int secs = totalSecs % 60; + char timeStr[16]; + snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs); + pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255}); +} + void GameRenderer::renderPlayingState( SDL_Renderer* renderer, Game* game, @@ -125,6 +288,7 @@ void GameRenderer::renderPlayingState( SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, + SDL_Texture* holdPanelTex, float logicalW, float logicalH, float logicalScale, @@ -164,64 +328,8 @@ void GameRenderer::renderPlayingState( const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT; const float maxBlockSizeW = availableWidth / Game::COLS; - const float maxBlockSizeH = availableHeight / Game::ROWS; - float previewY = rowTop - 4.0f; - const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f)); - - const float GRID_W = Game::COLS * finalBlockSize; - const float GRID_H = Game::ROWS * finalBlockSize; - - // Calculate positions - const float totalContentHeight = NEXT_PIECE_HEIGHT + GRID_H; - const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN; - const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f; - const float contentStartY = TOP_MARGIN + verticalCenterOffset; - - const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH; - const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f; - float barY = previewY + previewSize + 10.0f; - 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; - float rowBottom = percY + 14.0f; - SDL_FRect rowBg{ - previewX - 10.0f, - rowTop - 8.0f, - rowWidth + 20.0f, - rowBottom - rowTop - }; - 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 - if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) { - auto completedLines = game->getCompletedLines(); - lineEffect->startLineClear(completedLines, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize)); - } - - // Draw styled game grid border and semi-transparent background so the scene shows through. - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); - - // Outer glow layers (subtle, increasing spread, decreasing alpha) - drawRectWithOffset(gridX - 8 - contentOffsetX, gridY - 8 - contentOffsetY, GRID_W + 16, GRID_H + 16, {100, 120, 200, 28}); - drawRectWithOffset(gridX - 6 - contentOffsetX, gridY - 6 - contentOffsetY, GRID_W + 12, GRID_H + 12, {100, 120, 200, 40}); - - // Accent border (brighter, thin) - drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 220}); - drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - 1 - contentOffsetY, GRID_W + 2, GRID_H + 2, {60, 80, 160, 200}); - - // Do NOT fill the interior of the grid so the background shows through. - // (Intentionally leave the playfield interior transparent.) - - // Draw panel backgrounds - SDL_SetRenderDrawColor(renderer, 10, 15, 25, 160); - SDL_FRect lbg{statsX - 16, gridY - 10, statsW + 32, GRID_H + 20}; - SDL_RenderFillRect(renderer, &lbg); - - SDL_FRect rbg{scoreX - 16, gridY - 16, statsW + 32, GRID_H + 32}; - SDL_RenderFillRect(renderer, &rbg); + // Draw hold panel via helper + drawHoldPanel(renderer, game, pixelFont, blocksTex, holdPanelTex, scoreX, statsW, gridY, finalBlockSize, statsY, statsH); // Draw grid lines (solid so grid remains legible over background) SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); @@ -244,18 +352,9 @@ void GameRenderer::renderPlayingState( drawRectWithOffset(statsX - 3 - contentOffsetX, statsY - 3 - contentOffsetY, statsW + 6, statsH + 6, {100, 120, 200, 255}); drawRectWithOffset(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255}); - // Draw next piece preview panel border - // If a NEXT panel texture was provided, draw it instead of the custom - // background/outline. The texture will be scaled to fit the panel area. - if (nextPanelTex) { - SDL_FRect dst{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH }; - SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND); - SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst); - } else { - drawRectWithOffset(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255}); - drawRectWithOffset(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255}); - } - + // Draw next piece panel + drawNextPanel(renderer, pixelFont, nextPanelTex, blocksTex, game, nextX, nextY, nextW, nextH, contentOffsetX, contentOffsetY, finalBlockSize); + // Draw the game board const auto &board = game->boardRef(); for (int y = 0; y < Game::ROWS; ++y) { @@ -411,53 +510,8 @@ void GameRenderer::renderPlayingState( yCursor = rowBottom + rowSpacing; } - // Draw score panel (right side) - const float contentTopOffset = 0.0f; - const float contentBottomOffset = 290.0f; - const float contentPad = 36.0f; - float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad; - float baseY = gridY + (GRID_H - scoreContentH) * 0.5f; - - 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 - int startLv = game->startLevelBase(); - int firstThreshold = (startLv + 1) * 10; - int linesDone = game->lines(); - int nextThreshold = 0; - if (linesDone < firstThreshold) { - nextThreshold = firstThreshold; - } else { - int blocksPast = linesDone - firstThreshold; - nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10; - } - int linesForNext = std::max(0, nextThreshold - linesDone); - 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 display - pixelFont->draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255}); - int totalSecs = static_cast(game->elapsed()); - int mins = totalSecs / 60; - int secs = totalSecs % 60; - char timeStr[16]; - snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs); - pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255}); + // Draw score panel + drawScorePanel(renderer, pixelFont, game, scoreX, gridY, GRID_H, finalBlockSize); // Gravity HUD char gms[64]; @@ -466,10 +520,76 @@ void GameRenderer::renderPlayingState( snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps); pixelFont->draw(renderer, logicalW - 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, static_cast(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f); + // Hold panel (always visible): draw background & label; preview shown only when a piece is held. + { + float holdBlockH = (finalBlockSize * 0.6f) * 4.0f; + // Base panel height; enforce minimum but allow larger to fit texture + float panelH = std::max(holdBlockH + 12.0f, 420.0f); + // Increase height by ~20% of the hold block to give more vertical room + float extraH = holdBlockH * 0.50f; + panelH += extraH; + const float holdGap = 18.0f; + + // Align X to the bottom score label (`scoreX`) plus an offset to the right + float panelX = scoreX + 30.0f; // move ~30px right to align with score label + float panelW = statsW + 32.0f; + float panelY = gridY - panelH - holdGap; + // Move panel a bit higher for spacing (about half the extra height) + panelY -= extraH * 0.5f; + float labelX = panelX + 40.0f; // shift HOLD label ~30px to the right + float labelY = panelY + 8.0f; + + if (holdPanelTex) { + int texW = 0, texH = 0; + SDL_QueryTexture(holdPanelTex, nullptr, nullptr, &texW, &texH); + if (texW > 0 && texH > 0) { + // If the texture is taller than the current panel, expand panelH + float texAspect = float(texH) / float(texW); + float desiredTexH = panelW * texAspect; + if (desiredTexH + 12.0f > panelH) { + panelH = desiredTexH + 12.0f; + // Recompute vertical placement after growing panelH + panelY = gridY - panelH - holdGap; + labelY = panelY + 8.0f; + } + + // Fill panel width and compute destination height from texture aspect ratio + float texAspect = float(texH) / float(texW); + float dstW = panelW; + float dstH = dstW * texAspect * 1.2f; + // If texture height exceeds panel, expand panelH to fit texture comfortably + if (dstH + 12.0f > panelH) { + panelH = dstH + 12.0f; + panelY = gridY - panelH - holdGap; + labelY = panelY + 8.0f; + } + float dstX = panelX; + float dstY = panelY + (panelH - dstH) * 0.5f; + + SDL_FRect panelDst{dstX, dstY, dstW, dstH}; + SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND); + SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR); + SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst); + } else { + // Fallback to filling panel area if texture metrics unavailable + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); + SDL_FRect panelDst{panelX, panelY, panelW, panelH}; + SDL_RenderFillRect(renderer, &panelDst); + } + } else { + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); + SDL_FRect panelDst{panelX, panelY, panelW, panelH}; + SDL_RenderFillRect(renderer, &panelDst); + } + + pixelFont->draw(renderer, labelX, labelY, "HOLD", 1.0f, {255, 220, 0, 255}); + + if (game->held().type < PIECE_COUNT) { + float previewW = finalBlockSize * 0.6f * 4.0f; + float previewX = panelX + (panelW - previewW) * 0.5f; + float previewY = panelY + (panelH - holdBlockH) * 0.5f; + drawSmallPiece(renderer, blocksTex, static_cast(game->held().type), previewX, previewY, finalBlockSize * 0.6f); + } } // Pause overlay (suppressed when requested, e.g., countdown) diff --git a/src/graphics/GameRenderer.h b/src/graphics/GameRenderer.h index 5b074da..a677748 100644 --- a/src/graphics/GameRenderer.h +++ b/src/graphics/GameRenderer.h @@ -24,6 +24,7 @@ public: SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, + SDL_Texture* holdPanelTex, float logicalW, float logicalH, float logicalScale, diff --git a/src/graphics/challenge_mode.md b/src/graphics/challenge_mode.md new file mode 100644 index 0000000..97716ab --- /dev/null +++ b/src/graphics/challenge_mode.md @@ -0,0 +1,287 @@ +# Spacetris — Challenge Mode (Asteroids) Implementation Spec for VS Code AI Agent + +> Goal: Implement/extend **CHALLENGE** gameplay in Spacetris (not a separate mode), based on 100 levels with **asteroid** prefilled blocks that must be destroyed to advance. + +--- + +## 1) High-level Requirements + +### Modes +- Existing mode remains **ENDLESS**. +- Add/extend **CHALLENGE** mode with **100 levels**. + +### Core Challenge Loop +- Each level starts with **prefilled obstacle blocks** called **Asteroids**. +- **Level N** starts with **N asteroids** (placed increasingly higher as level increases). +- Player advances to the next level when **ALL asteroids are destroyed**. +- Gravity (and optionally lock pressure) increases per level. + +### Asteroid concept +Asteroids are special blocks placed into the grid at level start: +- They are **not** player-controlled pieces. +- They have **types** and **hit points** (how many times they must be cleared via line clears). + +--- + +## 2) Asteroid Types & Rules + +Define asteroid types and their behavior: + +### A) Normal Asteroid +- `hitsRemaining = 1` +- Removed when its row is cleared once. +- Never moves (no gravity). + +### B) Armored Asteroid +- `hitsRemaining = 2` +- On first line clear that includes it: decrement hits and change to cracked visual state. +- On second clear: removed. +- Never moves (no gravity). + +### C) Falling Asteroid +- `hitsRemaining = 2` +- On first clear: decrement hits, then **becomes gravity-enabled** (drops until resting). +- On second clear: removed. + +### D) Core Asteroid (late levels) +- `hitsRemaining = 3` +- On each clear: decrement hits and change visual state. +- After first hit (or after any hit — choose consistent rule) it becomes gravity-enabled. +- On final clear: removed (optionally trigger bigger VFX). + +**Important:** These are all within the same CHALLENGE mode. + +--- + +## 3) Level Progression Rules (100 Levels) + +### Asteroid Count +- `asteroidsToPlace = level` (Level 1 -> 1 asteroid, Level 2 -> 2 asteroids, …) +- Recommendation for implementation safety: + - If `level` becomes too large to place comfortably, still place `level` but distribute across more rows and allow overlaps only if empty. + - If needed, implement a soft cap for placement attempts (avoid infinite loops). If cannot place all, place as many as possible and log/telemetry. + +### Placement Height / Region +- Early levels: place in bottom 2–4 rows. +- Mid levels: bottom 6–10 rows. +- Late levels: up to ~half board height. +- Use a function to define a `minRow..maxRow` region based on `level`. + +Example guidance: +- `maxRow = boardHeight - 1` +- `minRow = boardHeight - 1 - clamp(2 + level/3, 2, boardHeight/2)` + +### Type Distribution by Level (suggested) +- Levels 1–9: Normal only +- Levels 10–19: add Armored (small %) +- Levels 20–59: add Falling (increasing %) +- Levels 60–100: add Core (increasing %) + +--- + +## 4) Difficulty Scaling + +### Gravity Speed Scaling +Implement per-level gravity scale: +- `gravity = baseGravity * (1.0f + level * 0.02f)` (tune) +- Or use a curve/table. + +Optional additional scaling: +- Reduced lock delay slightly at higher levels +- Slightly faster DAS/ARR (if implemented) + +--- + +## 5) Win/Lose Conditions + +### Level Completion +- Level completes when: `asteroidsRemaining == 0` +- Then: + - Clear board (or keep board — choose one consistent behavior; recommended: **clear board** for clean progression). + - Show short transition (optional). + - Load next level, until level 100. +- After level 100 completion: show completion screen + stats. + +### Game Over +- Standard Tetris game over: stack reaches spawn/top (existing behavior). + +--- + +## 6) Rendering / UI Requirements + +### Visual Differentiation +Asteroids must be visually distinct from normal tetromino blocks. + +Provide visual states: +- Normal: rock texture +- Armored: plated / darker +- Cracked: visible cracks +- Falling: glow rim / hazard stripes +- Core: pulsing inner core + +Minimum UI additions (Challenge): +- Display `LEVEL: X/100` +- Display `ASTEROIDS REMAINING: N` (or an icon counter) + +--- + +## 7) Data Structures (C++ Guidance) + +### Cell Representation +Each grid cell must store: +- Whether occupied +- If occupied: is it part of normal tetromino or an asteroid +- If asteroid: type + hitsRemaining + gravityEnabled + visualState + +Suggested enums: +```cpp +enum class CellKind { Empty, Tetromino, Asteroid }; + +enum class AsteroidType { Normal, Armored, Falling, Core }; + +struct AsteroidCell { + AsteroidType type; + uint8_t hitsRemaining; + bool gravityEnabled; + uint8_t visualState; // optional (e.g. 0..n) +}; + +struct Cell { + CellKind kind; + // For Tetromino: color/type id + // For Asteroid: AsteroidCell data +}; +```` + +--- + +## 8) Line Clear Processing Rules (Important) + +When a line is cleared: + +1. Detect full rows (existing). +2. For each cleared row: + + * For each cell: + + * If `kind == Asteroid`: + + * `hitsRemaining--` + * If `hitsRemaining == 0`: remove (cell becomes Empty) + * Else: + + * Update its visual state (cracked/damaged) + * If asteroid type is Falling/Core and rule says it becomes gravity-enabled on first hit: + + * `gravityEnabled = true` +3. After clearing rows and collapsing the grid: + + * Apply **asteroid gravity step**: + + * For all gravity-enabled asteroid cells: let them fall until resting. + * Ensure stable iteration (bottom-up scan). +4. Recount asteroids remaining; if 0 -> level complete. + +**Note:** Decide whether gravity-enabled asteroids fall immediately after the first hit (recommended) and whether they fall as individual cells (recommended) or as clusters (optional later). + +--- + +## 9) Asteroid Gravity Algorithm (Simple + Stable) + +Implement a pass: + +* Iterate from bottom-2 to top (bottom-up). +* If cell is gravity-enabled asteroid and below is empty: + + * Move down by one +* Repeat passes until no movement OR do a while-loop per cell to drop fully. + +Be careful to avoid skipping cells when moving: + +* Use bottom-up iteration and drop-to-bottom logic. + +--- + +## 10) Level Generation (Deterministic Option) + +To make challenge reproducible: + +* Use a seed: `seed = baseSeed + level` +* Place asteroids with RNG based on level seed. + +Placement constraints: + +* Avoid placing asteroids in the spawn zone/top rows. +* Avoid creating impossible scenarios too early: + + * For early levels, ensure at least one vertical shaft exists. + +--- + +## 11) Tasks Checklist for AI Agent + +### A) Add Challenge Level System + +* [ ] Add `currentLevel (1..100)` and `mode == CHALLENGE`. +* [ ] Add `StartChallengeLevel(level)` function. +* [ ] Reset/prepare board state for each level (recommended: clear board). + +### B) Asteroid Placement + +* [ ] Implement `PlaceAsteroids(level)`: + + * Determine region of rows + * Choose type distribution + * Place `level` asteroid cells into empty spots + +### C) Line Clear Hook + +* [ ] Modify existing line clear code: + + * Apply asteroid hit logic + * Update visuals + * Enable gravity where required + +### D) Gravity-enabled Asteroids + +* [ ] Implement `ApplyAsteroidGravity()` after line clears and board collapse. + +### E) Level Completion + +* [ ] Track `asteroidsRemaining`. +* [ ] When 0: trigger level transition and `StartChallengeLevel(level+1)`. + +### F) UI + +* [ ] Add level & asteroids remaining display. + +--- + +## 12) Acceptance Criteria + +* Level 1 spawns exactly 1 asteroid. +* Level N spawns N asteroids. +* Destroying asteroids requires: + + * Normal: 1 clear + * Armored: 2 clears + * Falling: 2 clears + becomes gravity-enabled after first hit + * Core: 3 clears (+ gravity-enabled rule) +* Player advances only when all asteroids are destroyed. +* Gravity increases by level and is clearly noticeable by mid-levels. +* No infinite loops in placement or gravity. +* Challenge works end-to-end through level 100. + +--- + +## 13) Notes / Tuning Hooks + +Expose tuning constants: + +* `baseGravity` +* `gravityPerLevel` +* `minAsteroidRow(level)` +* `typeDistribution(level)` weights +* `coreGravityOnHit` rule + +--- \ No newline at end of file diff --git a/src/graphics/effects/SpaceWarp.cpp b/src/graphics/effects/SpaceWarp.cpp index 31f2cb0..4ff5a0c 100644 --- a/src/graphics/effects/SpaceWarp.cpp +++ b/src/graphics/effects/SpaceWarp.cpp @@ -107,8 +107,22 @@ void SpaceWarp::spawnComet() { float normalizedAspect = std::max(aspect, MIN_ASPECT); float xRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? aspect : 1.0f); float yRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect)); - comet.x = randomRange(-xRange, xRange); - comet.y = randomRange(-yRange, yRange); + // Avoid spawning comets exactly on (or extremely near) the view axis, + // which can project to a nearly static bright dot. + const float axisMinFrac = 0.06f; + bool axisOk = false; + for (int attempt = 0; attempt < 10 && !axisOk; ++attempt) { + comet.x = randomRange(-xRange, xRange); + comet.y = randomRange(-yRange, yRange); + float nx = comet.x / std::max(xRange, 0.0001f); + float ny = comet.y / std::max(yRange, 0.0001f); + axisOk = (nx * nx + ny * ny) >= (axisMinFrac * axisMinFrac); + } + if (!axisOk) { + float ang = randomRange(0.0f, 6.28318530718f); + comet.x = std::cos(ang) * xRange * axisMinFrac; + comet.y = std::sin(ang) * yRange * axisMinFrac; + } comet.z = randomRange(minDepth + 4.0f, maxDepth); float baseSpeed = randomRange(settings.minSpeed, settings.maxSpeed); float multiplier = randomRange(settings.cometSpeedMultiplierMin, settings.cometSpeedMultiplierMax); @@ -154,9 +168,24 @@ void SpaceWarp::respawn(WarpStar& star, bool randomDepth) { float normalizedAspect = std::max(aspect, MIN_ASPECT); float xRange = settings.baseSpawnRange * (aspect >= 1.0f ? aspect : 1.0f); float yRange = settings.baseSpawnRange * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect)); - star.x = randomRange(-xRange, xRange); - star.y = randomRange(-yRange, yRange); - star.z = randomDepth ? randomRange(minDepth, maxDepth) : maxDepth; + // Avoid axis-aligned stars (x≈0,y≈0) which can project to a static, bright center dot. + const float axisMinFrac = 0.06f; + bool axisOk = false; + for (int attempt = 0; attempt < 10 && !axisOk; ++attempt) { + star.x = randomRange(-xRange, xRange); + star.y = randomRange(-yRange, yRange); + float nx = star.x / std::max(xRange, 0.0001f); + float ny = star.y / std::max(yRange, 0.0001f); + axisOk = (nx * nx + ny * ny) >= (axisMinFrac * axisMinFrac); + } + if (!axisOk) { + float ang = randomRange(0.0f, 6.28318530718f); + star.x = std::cos(ang) * xRange * axisMinFrac; + star.y = std::sin(ang) * yRange * axisMinFrac; + } + + // Keep z slightly above minDepth so projection never starts from the exact singular plane. + star.z = randomDepth ? randomRange(minDepth + 0.25f, maxDepth) : maxDepth; star.speed = randomRange(settings.minSpeed, settings.maxSpeed); star.shade = randomRange(settings.minShade, settings.maxShade); static constexpr Uint8 GRAY_SHADES[] = {160, 180, 200, 220, 240}; @@ -253,6 +282,13 @@ void SpaceWarp::update(float deltaSeconds) { continue; } + // If a star projects to (near) the visual center, it can appear perfectly static + // during straight-line flight. Replace it to avoid the "big static star" artifact. + if (std::abs(sx - centerX) < 1.25f && std::abs(sy - centerY) < 1.25f) { + respawn(star, true); + continue; + } + star.prevScreenX = star.screenX; star.prevScreenY = star.screenY; star.screenX = sx; diff --git a/src/graphics/effects/Starfield3D.cpp b/src/graphics/effects/Starfield3D.cpp index 06030c3..e9b65a1 100644 --- a/src/graphics/effects/Starfield3D.cpp +++ b/src/graphics/effects/Starfield3D.cpp @@ -68,9 +68,24 @@ void Starfield3D::setRandomDirection(Star3D& star) { void Starfield3D::updateStar(int index) { Star3D& star = stars[index]; - - star.x = randomFloat(-25.0f, 25.0f); - star.y = randomFloat(-25.0f, 25.0f); + + // Avoid spawning stars on (or very near) the view axis. A star with x≈0 and y≈0 + // projects to the exact center, and when it happens to be bright it looks like a + // static "big" star. + constexpr float SPAWN_RANGE = 25.0f; + constexpr float MIN_AXIS_RADIUS = 2.5f; // in star-space units + for (int attempt = 0; attempt < 8; ++attempt) { + star.x = randomFloat(-SPAWN_RANGE, SPAWN_RANGE); + star.y = randomFloat(-SPAWN_RANGE, SPAWN_RANGE); + if ((star.x * star.x + star.y * star.y) >= (MIN_AXIS_RADIUS * MIN_AXIS_RADIUS)) { + break; + } + } + // If we somehow still ended up too close, push it out deterministically. + if ((star.x * star.x + star.y * star.y) < (MIN_AXIS_RADIUS * MIN_AXIS_RADIUS)) { + star.x = (star.x < 0.0f ? -1.0f : 1.0f) * MIN_AXIS_RADIUS; + star.y = (star.y < 0.0f ? -1.0f : 1.0f) * MIN_AXIS_RADIUS; + } star.z = randomFloat(1.0f, MAX_DEPTH); // Give stars initial velocities in all possible directions @@ -91,6 +106,15 @@ void Starfield3D::updateStar(int index) { star.vz = -STAR_SPEED * randomFloat(0.8f, 1.2f); } } + + // Ensure newly spawned stars have some lateral drift so they don't appear to + // "stick" near the center line. + if (std::abs(star.vx) < 0.02f && std::abs(star.vy) < 0.02f) { + const float sx = (star.x < 0.0f ? -1.0f : 1.0f); + const float sy = (star.y < 0.0f ? -1.0f : 1.0f); + star.vx = sx * randomFloat(0.04f, 0.14f); + star.vy = sy * randomFloat(0.04f, 0.14f); + } star.targetVx = star.vx; star.targetVy = star.vy; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 18504f0..f1feabd 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -518,6 +518,7 @@ void GameRenderer::renderPlayingState( SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, + SDL_Texture* holdPanelTex, float logicalW, float logicalH, float logicalScale, @@ -1357,6 +1358,11 @@ void GameRenderer::renderPlayingState( statLines.push_back({dropStr, 370.0f, 0.7f, dropColor}); } + bool scorePanelMetricsValid = false; + float scorePanelTop = 0.0f; + float scorePanelLeftX = 0.0f; + float scorePanelWidth = 0.0f; + if (!statLines.empty()) { float statsContentTop = std::numeric_limits::max(); float statsContentBottom = std::numeric_limits::lowest(); @@ -1383,6 +1389,11 @@ void GameRenderer::renderPlayingState( SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); SDL_RenderFillRect(renderer, &statsBg); } + + scorePanelMetricsValid = true; + scorePanelTop = statsPanelTop; + scorePanelLeftX = statsPanelLeft; + scorePanelWidth = statsPanelWidth; } for (const auto& line : statLines) { @@ -1393,10 +1404,49 @@ void GameRenderer::renderPlayingState( pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255}); } - // Hold piece (if implemented) - if (game->held().type < PIECE_COUNT) { - pixelFont->draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255}); - drawSmallPiece(renderer, blocksTex, static_cast(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f); + // Hold panel background & label (always visible). Small preview renders only if a piece is held. + { + float holdLabelX = statsTextX; + float holdY = statsY + statsH - 80.0f; + float holdBlockH = (finalBlockSize * 0.6f) * 6.0f; + const float holdGap = 18.0f; + float panelW = 120.0f; + float panelH = holdBlockH + 12.0f; + float panelX = holdLabelX + 40.0f; + float panelY = holdY - 6.0f; + + if (scorePanelMetricsValid) { + // align panel to score panel width and position it above it + panelW = scorePanelWidth; + panelX = scorePanelLeftX; + panelY = scorePanelTop - panelH - holdGap; + // choose label X (left edge + padding) + holdLabelX = panelX + 10.0f; + // label Y inside panel + holdY = panelY + 8.0f; + } + + if (holdPanelTex) { + SDL_FRect panelDst{panelX, panelY, panelW, panelH}; + SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND); + SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR); + SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst); + } else { + // fallback: draw a dark panel rect so UI is visible even without texture + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); + SDL_FRect panelDst{panelX, panelY, panelW, panelH}; + SDL_RenderFillRect(renderer, &panelDst); + } + + // Display "HOLD" label on right side + pixelFont->draw(renderer, holdLabelX + 56.0f, holdY + 4.0f, "HOLD", 1.0f, {255, 220, 0, 255}); + + if (game->held().type < PIECE_COUNT) { + // Draw small held preview inside the panel (centered) + float previewX = panelX + (panelW - (finalBlockSize * 0.6f * 4.0f)) * 0.5f; + float previewY = panelY + (panelH - holdBlockH) * 2.5f; + drawSmallPiece(renderer, blocksTex, static_cast(game->held().type), previewX, previewY, finalBlockSize * 0.6f); + } } // Pause overlay logic moved to renderPauseOverlay diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index 14fd746..c4c730e 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -24,6 +24,7 @@ public: SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, + SDL_Texture* holdPanelTex, float logicalW, float logicalH, float logicalScale, diff --git a/src/graphics/renderers/RenderPrimitives.h b/src/graphics/renderers/RenderPrimitives.h new file mode 100644 index 0000000..5836ce2 --- /dev/null +++ b/src/graphics/renderers/RenderPrimitives.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace RenderPrimitives { + +inline void fillRect(SDL_Renderer* renderer, float x, float y, float w, float h, SDL_Color color) { + if (!renderer) { + return; + } + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + SDL_FRect rect{x, y, w, h}; + SDL_RenderFillRect(renderer, &rect); +} + +} // namespace RenderPrimitives diff --git a/src/graphics/renderers/UIRenderer.cpp b/src/graphics/renderers/UIRenderer.cpp index e86e754..3007b0c 100644 --- a/src/graphics/renderers/UIRenderer.cpp +++ b/src/graphics/renderers/UIRenderer.cpp @@ -39,10 +39,34 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f float x = cx - w * 0.5f; float y = cy - h * 0.5f; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + // In "textOnly" mode we don't draw a full button body (the art may be in the background image), + // but we still add a subtle highlight so hover/selection feels intentional. + if (textOnly && (isHovered || isSelected)) { + Uint8 outlineA = isSelected ? 170 : 110; + Uint8 fillA = isSelected ? 60 : 32; + + SDL_Color hl = borderColor; + hl.a = outlineA; + SDL_SetRenderDrawColor(renderer, hl.r, hl.g, hl.b, hl.a); + SDL_FRect o1{x - 3.0f, y - 3.0f, w + 6.0f, h + 6.0f}; + SDL_RenderRect(renderer, &o1); + SDL_FRect o2{x - 6.0f, y - 6.0f, w + 12.0f, h + 12.0f}; + SDL_SetRenderDrawColor(renderer, hl.r, hl.g, hl.b, static_cast(std::max(0, (int)hl.a - 60))); + SDL_RenderRect(renderer, &o2); + + SDL_Color fill = bgColor; + fill.a = fillA; + SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); + SDL_FRect f{x, y, w, h}; + SDL_RenderFillRect(renderer, &f); + } + if (!textOnly) { // Adjust colors based on state if (isSelected) { - bgColor = {160, 190, 255, 255}; + // Keep caller-provided colors; just add a stronger glow. SDL_SetRenderDrawColor(renderer, 255, 220, 0, 110); SDL_FRect glow{x - 10, y - 10, w + 20, h + 20}; SDL_RenderFillRect(renderer, &glow); @@ -54,7 +78,6 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f } // Neon glow aura around the button to increase visibility (subtle) - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); for (int gi = 0; gi < 3; ++gi) { float grow = 6.0f + gi * 3.0f; Uint8 glowA = static_cast(std::max(0, (int)borderColor.a / (3 - gi))); @@ -89,30 +112,42 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f float iconX = cx - scaledW * 0.5f; float iconY = cy - scaledH * 0.5f; - // Apply yellow tint when selected + SDL_FRect iconRect{iconX, iconY, scaledW, scaledH}; + + // Soft icon shadow for readability over busy backgrounds + SDL_SetTextureBlendMode(icon, SDL_BLENDMODE_BLEND); + SDL_SetTextureColorMod(icon, 0, 0, 0); + SDL_SetTextureAlphaMod(icon, 150); + SDL_FRect shadowRect{iconX + 2.0f, iconY + 2.0f, scaledW, scaledH}; + SDL_RenderTexture(renderer, icon, nullptr, &shadowRect); + + // Main icon (yellow tint when selected) if (isSelected) { SDL_SetTextureColorMod(icon, 255, 220, 0); } else { SDL_SetTextureColorMod(icon, 255, 255, 255); } - - SDL_FRect iconRect{iconX, iconY, scaledW, scaledH}; + SDL_SetTextureAlphaMod(icon, 255); SDL_RenderTexture(renderer, icon, nullptr, &iconRect); - - // Reset color mod + + // Reset SDL_SetTextureColorMod(icon, 255, 255, 255); + SDL_SetTextureAlphaMod(icon, 255); } else if (font) { - // Draw text (smaller scale for tighter buttons) + // Draw text with scale based on button height. float textScale = 1.2f; + if (h <= 40.0f) { + textScale = 0.90f; + } else if (h <= 54.0f) { + textScale = 1.00f; + } else if (h <= 70.0f) { + textScale = 1.10f; + } int textW = 0, textH = 0; font->measure(label, textScale, textW, textH); float tx = x + (w - static_cast(textW)) * 0.5f; - // Adjust vertical position for better alignment with background buttons - // Vertically center text precisely within the button - // Vertically center text precisely within the button, then nudge down slightly - // to improve optical balance relative to icons and button art. - const float textNudge = 3.0f; // tweak this value to move labels up/down - float ty = y + (h - static_cast(textH)) * 0.5f + textNudge; + // Vertically center text within the button. + float ty = y + (h - static_cast(textH)) * 0.5f; // Choose text color based on selection state SDL_Color textColor = {255, 255, 255, 255}; // Default white diff --git a/src/graphics/ui/HelpOverlay.cpp b/src/graphics/ui/HelpOverlay.cpp index 3afdf2b..356ba59 100644 --- a/src/graphics/ui/HelpOverlay.cpp +++ b/src/graphics/ui/HelpOverlay.cpp @@ -34,7 +34,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l if (!renderer) return; const std::array generalShortcuts{{ - {"H", "Toggle this help overlay"}, + {"F1", "Toggle this help overlay"}, {"ESC", "Back / cancel current popup"}, {"F11 or ALT+ENTER", "Toggle fullscreen"}, {"M", "Mute or unmute music"}, @@ -46,11 +46,12 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l {"ENTER / SPACE", "Activate highlighted action"} }}; - const std::array gameplayShortcuts{{ + const std::array gameplayShortcuts{{ {"LEFT / RIGHT", "Move active piece"}, {"DOWN", "Soft drop (faster fall)"}, {"SPACE", "Hard drop / instant lock"}, {"UP", "Rotate clockwise"}, + {"H", "Hold / swap current piece"}, {"X", "Toggle rotation direction used by UP"}, {"P", "Pause or resume"}, {"ESC", "Open exit confirmation"} @@ -134,7 +135,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l SDL_SetRenderDrawColor(renderer, 90, 110, 170, 255); SDL_RenderRect(renderer, &footerRect); - const char* closeLabel = "PRESS H OR ESC TO CLOSE"; + const char* closeLabel = "PRESS F1 OR ESC TO CLOSE"; float closeScale = fitScale(font, closeLabel, 1.0f, footerRect.w - footerPadding * 2.0f); int closeW = 0, closeH = 0; font.measure(closeLabel, closeScale, closeW, closeH); diff --git a/src/main.cpp b/src/main.cpp index 1971586..e88774b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,2066 +1,15 @@ -// main.cpp - Application orchestration (initialization, loop, UI states) -// High-level only: delegates Tetris logic, scores, background, font rendering. +// main.cpp - Thin entrypoint. +// +// The full SDL initialization, loading screen, state machine, game loop, and shutdown +// are intentionally kept out of this file (see `TetrisApp`) to keep the entrypoint +// small and keep orchestration separate from gameplay/state code. -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#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 "utils/ImagePathResolver.h" -#include "graphics/renderers/GameRenderer.h" -#include "core/Config.h" -#include "core/Settings.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 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); -} - -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); - SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); - if (!surface) { - 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) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError()); - return nullptr; - } - - 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(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f); - SDL_SetTextureAlphaMod(tex, alpha); - SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, ¢er, 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(winW), static_cast(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 -// ----------------------------------------------------------------------------- -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; - -// ----------------------------------------------------------------------------- -// Tetris Block Fireworks for intro animation (block particles) -// Forward declare block render helper used by particles -// Forward declare block render helper used by particles -// (Note: drawBlockTexture implementation was removed, so this is likely dead code unless particles use it. -// However, particles use drawFireworks_impl which uses SDL_RenderGeometry, so this is unused.) -// ----------------------------------------------------------------------------- -struct BlockParticle { - float x{}, y{}; - float vx{}, vy{}; - float size{}, alpha{}, decay{}; - float wobblePhase{}, wobbleSpeed{}; - float coreHeat{}; - BlockParticle(float sx, float sy) - : x(sx), y(sy) { - const float spreadDeg = 35.0f; - const float angleDeg = -90.0f + spreadDeg * ((rand() % 200) / 100.0f - 1.0f); // bias upward - const float angleRad = angleDeg * 3.1415926f / 180.0f; - float speed = 1.3f + (rand() % 220) / 80.0f; // ~1.3..4.05 - vx = std::cos(angleRad) * speed * 0.55f; - vy = std::sin(angleRad) * speed; - size = 6.0f + (rand() % 40) / 10.0f; // 6..10 px - alpha = 1.0f; - decay = 0.0095f + (rand() % 180) / 12000.0f; // 0.0095..0.0245 - wobblePhase = (rand() % 628) / 100.0f; - wobbleSpeed = 0.08f + (rand() % 60) / 600.0f; - coreHeat = 0.65f + (rand() % 35) / 100.0f; - } - bool update() { - vx *= 0.992f; - vy = vy * 0.985f - 0.015f; // buoyancy pushes upward (negative vy) - x += vx; - y += vy; - wobblePhase += wobbleSpeed; - x += std::sin(wobblePhase) * 0.12f; - alpha -= decay; - size = std::max(1.8f, size - 0.03f); - coreHeat = std::max(0.0f, coreHeat - decay * 0.6f); - return alpha > 0.03f; - } -}; - -struct TetrisFirework { - std::vector 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 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 SDL_Color blendFireColor(float heat, float alphaScale, Uint8 minG, Uint8 minB) { - heat = std::clamp(heat, 0.0f, 1.0f); - Uint8 r = 255; - Uint8 g = static_cast(std::clamp(120.0f + heat * (255.0f - 120.0f), float(minG), 255.0f)); - Uint8 b = static_cast(std::clamp(40.0f + (1.0f - heat) * 60.0f, float(minB), 255.0f)); - Uint8 a = static_cast(std::clamp(alphaScale * 255.0f, 0.0f, 255.0f)); - return SDL_Color{r, g, b, a}; -} - -static void drawFireworks_impl(SDL_Renderer* renderer, SDL_Texture*) { - SDL_BlendMode previousBlend = SDL_BLENDMODE_NONE; - SDL_GetRenderDrawBlendMode(renderer, &previousBlend); - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); - - static constexpr int quadIndices[6] = {0, 1, 2, 2, 1, 3}; - - auto makeVertex = [](float px, float py, SDL_Color c) { - SDL_Vertex v{}; - v.position.x = px; - v.position.y = py; - v.color = SDL_FColor{ - c.r / 255.0f, - c.g / 255.0f, - c.b / 255.0f, - c.a / 255.0f - }; - return v; - }; - - for (auto& f : fireworks) { - for (auto &p : f.particles) { - const float heat = std::clamp(p.alpha * 1.25f + p.coreHeat * 0.5f, 0.0f, 1.0f); - SDL_Color glowColor = blendFireColor(0.45f + heat * 0.55f, p.alpha * 0.55f, 100, 40); - SDL_Color tailBaseColor = blendFireColor(heat * 0.75f, p.alpha * 0.5f, 70, 25); - SDL_Color tailTipColor = blendFireColor(heat * 0.35f, p.alpha * 0.2f, 40, 15); - SDL_Color coreColor = blendFireColor(heat, std::min(1.0f, p.alpha * 1.1f), 150, 80); - - float velLen = std::sqrt(p.vx * p.vx + p.vy * p.vy); - SDL_FPoint dir = velLen > 0.001f ? SDL_FPoint{p.vx / velLen, p.vy / velLen} - : SDL_FPoint{0.0f, -1.0f}; - SDL_FPoint perp{-dir.y, dir.x}; - - const float baseWidth = std::max(0.8f, p.size * 0.55f); - const float tipWidth = baseWidth * 0.35f; - const float tailLen = p.size * (3.0f + (1.0f - p.alpha) * 1.8f); - - SDL_FPoint base{p.x, p.y}; - SDL_FPoint tip{p.x + dir.x * tailLen, p.y + dir.y * tailLen}; - - SDL_Vertex tailVerts[4]; - tailVerts[0] = makeVertex(base.x + perp.x * baseWidth, base.y + perp.y * baseWidth, tailBaseColor); - tailVerts[1] = makeVertex(base.x - perp.x * baseWidth, base.y - perp.y * baseWidth, tailBaseColor); - tailVerts[2] = makeVertex(tip.x + perp.x * tipWidth, tip.y + perp.y * tipWidth, tailTipColor); - tailVerts[3] = makeVertex(tip.x - perp.x * tipWidth, tip.y - perp.y * tipWidth, tailTipColor); - SDL_RenderGeometry(renderer, nullptr, tailVerts, 4, quadIndices, 6); - - const float glowAlong = p.size * 0.95f; - const float glowAcross = p.size * 0.6f; - SDL_Vertex glowVerts[4]; - glowVerts[0] = makeVertex(base.x + dir.x * glowAlong, base.y + dir.y * glowAlong, glowColor); - glowVerts[1] = makeVertex(base.x - dir.x * glowAlong, base.y - dir.y * glowAlong, glowColor); - glowVerts[2] = makeVertex(base.x + perp.x * glowAcross, base.y + perp.y * glowAcross, glowColor); - glowVerts[3] = makeVertex(base.x - perp.x * glowAcross, base.y - perp.y * glowAcross, glowColor); - SDL_RenderGeometry(renderer, nullptr, glowVerts, 4, quadIndices, 6); - - const float coreWidth = p.size * 0.35f; - const float coreHeight = p.size * 0.9f; - SDL_Vertex coreVerts[4]; - coreVerts[0] = makeVertex(base.x + perp.x * coreWidth, base.y + perp.y * coreWidth, coreColor); - coreVerts[1] = makeVertex(base.x - perp.x * coreWidth, base.y - perp.y * coreWidth, coreColor); - coreVerts[2] = makeVertex(base.x + dir.x * coreHeight, base.y + dir.y * coreHeight, coreColor); - coreVerts[3] = makeVertex(base.x - dir.x * coreHeight, base.y - dir.y * coreHeight, coreColor); - SDL_RenderGeometry(renderer, nullptr, coreVerts, 4, quadIndices, 6); - } - } - - SDL_SetRenderDrawBlendMode(renderer, previousBlend); -} -// External wrappers retained for compatibility; now no-ops to disable the legacy fireworks effect. -void menu_drawFireworks(SDL_Renderer*, SDL_Texture*) {} -void menu_updateFireworks(double) {} -double menu_getLogoAnimCounter() { return logoAnimCounter; } -int menu_getHoveredButton() { return hoveredButton; } +#include "app/TetrisApp.h" int main(int, char **) { - // Initialize random seed for procedural effects - srand(static_cast(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()); - } - - // Primary UI font (Orbitron) used for major UI text: buttons, loading, HUD - FontAtlas pixelFont; - pixelFont.init(AssetPath::resolveWithBase("assets/fonts/Orbitron.ttf"), 22); - - // Secondary font (Exo2) used for longer descriptions, settings, credits - FontAtlas font; - font.init(AssetPath::resolveWithBase("assets/fonts/Exo2.ttf"), 20); - - ScoreManager scores; - std::atomic 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); - - // Load logo assets via SDL_image so we can use compressed formats - SDL_Texture* logoTex = loadTextureFromImage(renderer, "assets/images/spacetris.png"); - - // Load small logo (used by Menu to show whole logo) - int logoSmallW = 0, logoSmallH = 0; - SDL_Texture* logoSmallTex = loadTextureFromImage(renderer, "assets/images/spacetris.png", &logoSmallW, &logoSmallH); - - // Load menu background using SDL_image (prefers JPEG) - SDL_Texture* backgroundTex = loadTextureFromImage(renderer, "assets/images/main_background.bmp"); - - // Load the new main screen overlay that sits above the background but below buttons - int mainScreenW = 0; - int mainScreenH = 0; - SDL_Texture* mainScreenTex = loadTextureFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH); - if (mainScreenTex) { - SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND); - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded main_screen overlay %dx%d (tex=%p)", mainScreenW, mainScreenH, (void*)mainScreenTex); - FILE* f = fopen("tetris_trace.log", "a"); - if (f) { - fprintf(f, "main.cpp: loaded main_screen.bmp %dx%d tex=%p\n", mainScreenW, mainScreenH, (void*)mainScreenTex); - fclose(f); - } - } else { - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Failed to load assets/images/main_screen.bmp (overlay will be skipped)"); - FILE* f = fopen("tetris_trace.log", "a"); - if (f) { - fprintf(f, "main.cpp: failed to load main_screen.bmp\n"); - fclose(f); - } - } - - // 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 - LevelBackgroundFader levelBackgrounds; - - // Default start level selection: 0 (declare here so it's in scope for all handlers) - int startLevelSelection = 0; - - // Load blocks texture via SDL_image (falls back to procedural blocks if missing) - SDL_Texture* blocksTex = loadTextureFromImage(renderer, "assets/images/blocks90px_001.bmp"); - // No global exposure of blocksTex; states receive textures via StateContext. - - if (!blocksTex) { - // Create a 630x90 texture (7 blocks * 90px each) - blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90); - - // Generate blocks by drawing colored rectangles to texture - 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); - } - - SDL_Texture* scorePanelTex = loadTextureFromImage(renderer, "assets/images/panel_score.png"); - if (scorePanelTex) { - SDL_SetTextureBlendMode(scorePanelTex, SDL_BLENDMODE_BLEND); - } - SDL_Texture* statisticsPanelTex = loadTextureFromImage(renderer, "assets/images/statistics_panel.png"); - if (statisticsPanelTex) { - SDL_SetTextureBlendMode(statisticsPanelTex, SDL_BLENDMODE_BLEND); - } - SDL_Texture* nextPanelTex = loadTextureFromImage(renderer, "assets/images/next_panel.png"); - if (nextPanelTex) { - SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND); - } - - Game game(startLevelSelection); - // Apply global gravity speed multiplier from config - game.setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); - game.reset(startLevelSelection); - - // Initialize sound effects system - SoundEffectManager::instance().init(); - - auto loadAudioAsset = [](const std::string& basePath, const std::string& id) { - std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" }); - if (resolved.empty()) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Missing audio asset for %s (base %s)", id.c_str(), basePath.c_str()); - return; - } - if (!SoundEffectManager::instance().loadSound(id, resolved)) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load %s from %s", id.c_str(), resolved.c_str()); - } - }; - - loadAudioAsset("assets/music/clear_line", "clear_line"); - - // Load voice lines for line clears using WAV files (with MP3 fallback) - std::vector singleSounds = {"well_played", "smooth_clear", "great_move"}; - std::vector doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; - std::vector tripleSounds = {"impressive", "triple_strike"}; - std::vector tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"}; - std::vector allVoiceSounds; - auto appendVoices = [&allVoiceSounds](const std::vector& src) { - allVoiceSounds.insert(allVoiceSounds.end(), src.begin(), src.end()); - }; - appendVoices(singleSounds); - appendVoices(doubleSounds); - appendVoices(tripleSounds); - appendVoices(tetrisSounds); - - auto loadVoice = [&](const std::string& id, const std::string& baseName) { - loadAudioAsset("assets/music/" + baseName, id); - }; - - loadVoice("nice_combo", "nice_combo"); - loadVoice("you_fire", "you_fire"); - loadVoice("well_played", "well_played"); - loadVoice("keep_that_ryhtm", "keep_that_ryhtm"); - loadVoice("great_move", "great_move"); - loadVoice("smooth_clear", "smooth_clear"); - loadVoice("impressive", "impressive"); - loadVoice("triple_strike", "triple_strike"); - loadVoice("amazing", "amazing"); - loadVoice("you_re_unstoppable", "you_re_unstoppable"); - loadVoice("boom_tetris", "boom_tetris"); - loadVoice("wonderful", "wonderful"); - loadVoice("lets_go", "lets_go"); - loadVoice("hard_drop", "hard_drop_001"); - loadVoice("new_level", "new_level"); - - bool suppressLineVoiceForLevelUp = false; - - auto playVoiceCue = [&](int linesCleared) { - const std::vector* 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(); - bool musicStarted = false; - bool musicLoaded = false; - int currentTrackLoading = 0; - int totalTracks = 0; // Will be set dynamically based on actual files - - 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 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 = backgroundTex; - 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(ctx); - auto menuState = std::make_unique(ctx); - auto optionsState = std::make_unique(ctx); - auto levelSelectorState = std::make_unique(ctx); - auto playingState = std::make_unique(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(); }); - - 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(); }); - - // 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 { - // 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; - float spacing = isSmall ? btnW * 1.15f : btnW * 1.05f; - std::array buttonRects{}; - for (int i = 0; i < 5; ++i) { - float center = btnCX + (static_cast(i) - 2.0f) * spacing; - buttonRects[i] = SDL_FRect{center - btnW / 2.0f, btnCY - btnH / 2.0f, btnW, btnH}; - } - - 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{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 = 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; - 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; - float spacing = isSmall ? btnW * 1.15f : btnW * 1.05f; - hoveredButton = -1; - for (int i = 0; i < 4; ++i) { - float center = btnCX + (static_cast(i) - 1.5f) * spacing; - SDL_FRect rect{center - btnW / 2.0f, btnCY - btnH / 2.0f, btnW, btnH}; - if (lx >= rect.x && lx <= rect.x + rect.w && ly >= rect.y && ly <= rect.y + rect.h) { - hoveredButton = i; - break; - } - } - } - } - } - } - } - - // --- 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); - 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) - { - // Initialize audio system and start background loading on first frame - if (!musicLoaded && currentTrackLoading == 0) { - Audio::instance().init(); - // Apply audio settings - Audio::instance().setMuted(!Settings::instance().isMusicEnabled()); - // Note: SoundEffectManager doesn't have a global mute yet, but we can add it or handle it in playSound - - // Count actual music files first - totalTracks = 0; - std::vector trackPaths; - trackPaths.reserve(100); - for (int i = 1; i <= 100; ++i) { - char base[64]; - std::snprintf(base, sizeof(base), "assets/music/music%03d", i); - std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" }); - if (path.empty()) { - break; - } - trackPaths.push_back(path); - } - totalTracks = static_cast(trackPaths.size()); - - for (const auto& track : trackPaths) { - Audio::instance().addTrackAsync(track); - } - - // 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 loading is complete OR we've loaded all expected tracks (handles potential thread cleanup hang) - if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) { - 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); - - // Fix floating point precision issues (0.2 + 0.7 + 0.1 can be 0.9999...) - 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 - updateLevelBackgroundFade(levelBackgrounds, 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; - } - - 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(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); - queueLevelBackground(levelBackgrounds, renderer, bgLevel); - renderLevelBackgrounds(levelBackgrounds, renderer, winW, winH, static_cast(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) { - if (backgroundTex) { - SDL_FRect fullRect = { 0, 0, (float)winW, (float)winH }; - 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(lw); - const float scaleFactorHeight = availableHeight / static_cast(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::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(nameW)) * 0.5f + contentOffsetX; - float textY = inputY + (inputH - static_cast(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(cursorW)) * 0.5f + contentOffsetX - : textX + static_cast(nameW); - float cursorY = inputY + (inputH - static_cast(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(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); - const char* label = GAMEPLAY_COUNTDOWN_LABELS[cappedIndex]; - bool isFinalCue = (cappedIndex == static_cast(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(textW)) * 0.5f; - float textY = (winH - static_cast(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); - } - - // Top-layer overlay: render `mainScreenTex` above all other layers when in Menu - if (state == AppState::Menu && mainScreenTex) { - SDL_SetRenderViewport(renderer, nullptr); - SDL_SetRenderScale(renderer, 1.f, 1.f); - float texW = mainScreenW > 0 ? static_cast(mainScreenW) : 0.0f; - float texH = mainScreenH > 0 ? static_cast(mainScreenH) : 0.0f; - if (texW <= 0.0f || texH <= 0.0f) { - float iwf = 0.0f, ihf = 0.0f; - if (SDL_GetTextureSize(mainScreenTex, &iwf, &ihf) != 0) { - iwf = ihf = 0.0f; - } - texW = iwf; - texH = ihf; - } - if (texW > 0.0f && texH > 0.0f) { - const float drawH = static_cast(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); - } - // Restore logical viewport/scale and draw the main PLAY button above the overlay - SDL_SetRenderViewport(renderer, &logicalVP); - SDL_SetRenderScale(renderer, logicalScale, logicalScale); - if (menuState) { - menuState->drawMainButtonNormally = false; // ensure it isn't double-drawn - menuState->renderMainButtonTop(renderer, logicalScale, logicalVP); - menuState->drawMainButtonNormally = true; - } - } - - SDL_RenderPresent(renderer); - SDL_SetRenderScale(renderer, 1.f, 1.f); - } - if (logoTex) - SDL_DestroyTexture(logoTex); - if (backgroundTex) - SDL_DestroyTexture(backgroundTex); - if (mainScreenTex) - SDL_DestroyTexture(mainScreenTex); - resetLevelBackgrounds(levelBackgrounds); - 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; + TetrisApp app; + return app.run(); } diff --git a/src/resources/AssetPaths.h b/src/resources/AssetPaths.h new file mode 100644 index 0000000..ba58799 --- /dev/null +++ b/src/resources/AssetPaths.h @@ -0,0 +1,17 @@ +// Centralized asset path constants +#pragma once + +namespace Assets { + inline constexpr const char* FONT_ORBITRON = "assets/fonts/Orbitron.ttf"; + inline constexpr const char* FONT_EXO2 = "assets/fonts/Exo2.ttf"; + + inline constexpr const char* LOGO = "assets/images/spacetris.png"; + inline constexpr const char* MAIN_SCREEN = "assets/images/main_screen.png"; + inline constexpr const char* BLOCKS_SPRITE = "assets/images/blocks90px_003.png"; + inline constexpr const char* PANEL_SCORE = "assets/images/panel_score.png"; + inline constexpr const char* PANEL_STATS = "assets/images/statistics_panel.png"; + inline constexpr const char* NEXT_PANEL = "assets/images/next_panel.png"; + inline constexpr const char* HOLD_PANEL = "assets/images/hold_panel.png"; + + inline constexpr const char* MUSIC_DIR = "assets/music/"; +} diff --git a/src/states/LoadingManager.cpp b/src/states/LoadingManager.cpp new file mode 100644 index 0000000..d87f9ce --- /dev/null +++ b/src/states/LoadingManager.cpp @@ -0,0 +1,39 @@ +#include "states/LoadingManager.h" +#include "app/AssetLoader.h" + +LoadingManager::LoadingManager(AssetLoader* loader) + : m_loader(loader) +{ +} + +void LoadingManager::queueTexture(const std::string& path) { + if (!m_loader) return; + m_loader->queueTexture(path); +} + +void LoadingManager::start() { + m_started = true; +} + +bool LoadingManager::update() { + if (!m_loader) return true; + // perform a single step on the loader; AssetLoader::performStep returns true when + // there are no more queued tasks. + bool done = m_loader->performStep(); + return done; +} + +float LoadingManager::getProgress() const { + if (!m_loader) return 1.0f; + return m_loader->getProgress(); +} + +std::vector LoadingManager::getAndClearErrors() { + if (!m_loader) return {}; + return m_loader->getAndClearErrors(); +} + +std::string LoadingManager::getCurrentLoading() const { + if (!m_loader) return {}; + return m_loader->getCurrentLoading(); +} diff --git a/src/states/LoadingManager.h b/src/states/LoadingManager.h new file mode 100644 index 0000000..94eb0bf --- /dev/null +++ b/src/states/LoadingManager.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +class AssetLoader; + +// LoadingManager: thin facade over AssetLoader for incremental loading. +// Main thread only. Call update() once per frame to perform a single step. +class LoadingManager { +public: + explicit LoadingManager(AssetLoader* loader); + + // Queue a texture path (relative to base path) for loading. + void queueTexture(const std::string& path); + + // Start loading (idempotent). + void start(); + + // Perform a single loading step. Returns true when loading complete. + bool update(); + + // Progress in [0,1] + float getProgress() const; + + // Return and clear any accumulated loading errors. + std::vector getAndClearErrors(); + + // Current path being loaded (or empty) + std::string getCurrentLoading() const; + +private: + AssetLoader* m_loader = nullptr; + bool m_started = false; +}; diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 2f731a7..d873037 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -25,6 +25,8 @@ #include "../utils/ImagePathResolver.h" #include "../graphics/renderers/UIRenderer.h" #include "../graphics/renderers/GameRenderer.h" +#include "../ui/MenuLayout.h" +#include "../ui/BottomMenu.h" #include // Frosted tint helper: draw a safe, inexpensive frosted overlay for the panel area. @@ -110,6 +112,11 @@ MenuState::MenuState(StateContext& ctx) : State(ctx) {} void MenuState::showHelpPanel(bool show) { if (show) { if (!helpPanelVisible && !helpPanelAnimating) { + // Avoid overlapping panels + if (aboutPanelVisible && !aboutPanelAnimating) { + aboutPanelAnimating = true; + aboutDirection = -1; + } helpPanelAnimating = true; helpDirection = 1; helpScroll = 0.0; @@ -122,6 +129,38 @@ void MenuState::showHelpPanel(bool show) { } } +void MenuState::showAboutPanel(bool show) { + if (show) { + if (!aboutPanelVisible && !aboutPanelAnimating) { + // Avoid overlapping panels + if (helpPanelVisible && !helpPanelAnimating) { + helpPanelAnimating = true; + helpDirection = -1; + } + if (optionsVisible && !optionsAnimating) { + optionsAnimating = true; + optionsDirection = -1; + } + if (levelPanelVisible && !levelPanelAnimating) { + levelPanelAnimating = true; + levelDirection = -1; + } + if (exitPanelVisible && !exitPanelAnimating) { + exitPanelAnimating = true; + exitDirection = -1; + if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; + } + aboutPanelAnimating = true; + aboutDirection = 1; + } + } else { + if (aboutPanelVisible && !aboutPanelAnimating) { + aboutPanelAnimating = true; + aboutDirection = -1; + } + } +} + void MenuState::onEnter() { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState::onEnter called"); if (ctx.showExitConfirmPopup) { @@ -135,103 +174,25 @@ void MenuState::onEnter() { void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { const float LOGICAL_W = 1200.f; const float LOGICAL_H = 1000.f; - float contentOffsetX = 0.0f; - float contentOffsetY = 0.0f; - UIRenderer::computeContentOffsets((float)logicalVP.w, (float)logicalVP.h, LOGICAL_W, LOGICAL_H, logicalScale, contentOffsetX, contentOffsetY); - float contentW = LOGICAL_W * logicalScale; - bool isSmall = (contentW < 700.0f); - float btnW = 200.0f; - float btnH = 70.0f; - float btnX = LOGICAL_W * 0.5f + contentOffsetX; - // move buttons a bit lower for better visibility - // small global vertical offset for the whole menu (tweak to move UI down) - float menuYOffset = LOGICAL_H * 0.03f; - float btnY = LOGICAL_H * 0.865f + contentOffsetY + (LOGICAL_H * 0.02f) + menuYOffset + 4.5f; - - // Compose same button definition used in render() - char levelBtnText[32]; - int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; - std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); - - struct MenuButtonDef { SDL_Color bg; SDL_Color border; std::string label; }; - std::array buttons = { - MenuButtonDef{ SDL_Color{60,180,80,255}, SDL_Color{30,120,40,255}, "PLAY" }, - MenuButtonDef{ SDL_Color{40,140,240,255}, SDL_Color{20,100,200,255}, levelBtnText }, - MenuButtonDef{ SDL_Color{130,80,210,255}, SDL_Color{90,40,170,255}, "OPTIONS" }, - MenuButtonDef{ SDL_Color{200,200,60,255}, SDL_Color{150,150,40,255}, "HELP" }, - MenuButtonDef{ SDL_Color{200,70,70,255}, SDL_Color{150,40,40,255}, "EXIT" } + // Use the same layout code as mouse hit-testing so each button is the same size. + ui::MenuLayoutParams params{ + static_cast(LOGICAL_W), + static_cast(LOGICAL_H), + logicalVP.w, + logicalVP.h, + logicalScale }; - std::array icons = { playIcon, levelIcon, optionsIcon, helpIcon, exitIcon }; + int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; + ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel); - float spacing = isSmall ? btnW * 1.2f : btnW * 1.15f; - - - - // Draw semi-transparent background panel behind the full button group (draw first so text sits on top) - // `groupCenterY` is declared here so it can be used when drawing the buttons below. - float groupCenterY = 0.0f; - { - float groupCenterX = btnX; - float halfSpan = 1.5f * spacing; // covers from leftmost to rightmost button centers - float panelLeft = groupCenterX - halfSpan - btnW * 0.5f - 14.0f; - float panelRight = groupCenterX + halfSpan + btnW * 0.5f + 14.0f; - // Nudge the panel slightly lower for better visual spacing - float panelTop = btnY - btnH * 0.5f - 12.0f + 18.0f; - float panelH = btnH + 24.0f; - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); - // Backdrop blur pass before tint (use captured scene texture if available) - renderBackdropBlur(renderer, logicalVP, logicalScale, panelTop, panelH, ctx.sceneTex, ctx.sceneW, ctx.sceneH); - // Brighter, more transparent background to increase contrast but keep scene visible - // More transparent background so underlying scene shows through - SDL_SetRenderDrawColor(renderer, 28, 36, 46, 110); - // Fill full-width background so edges are covered in fullscreen - float viewportLogicalW = (float)logicalVP.w / logicalScale; - SDL_FRect fullPanel{ 0.0f, panelTop, viewportLogicalW, panelH }; - SDL_RenderFillRect(renderer, &fullPanel); - // Also draw the central strip to keep visual center emphasis - SDL_FRect panelRect{ panelLeft, panelTop, panelRight - panelLeft, panelH }; - SDL_RenderFillRect(renderer, &panelRect); - // brighter full-width border (slightly more transparent) - SDL_SetRenderDrawColor(renderer, 120, 140, 160, 120); - // Expand border to cover full window width (use actual viewport) - SDL_FRect borderFull{ 0.0f, panelTop, viewportLogicalW, panelH }; - SDL_RenderRect(renderer, &borderFull); - // Compute a vertical center for the group so labels/icons can be centered - groupCenterY = panelTop + panelH * 0.5f; - } - - // Draw all five buttons on top - for (int i = 0; i < 5; ++i) { - float cxCenter = 0.0f; - // Use the group's center Y so text/icons sit visually centered in the panel - float cyCenter = groupCenterY; - if (ctx.menuButtonsExplicit) { - cxCenter = ctx.menuButtonCX[i] + contentOffsetX; - cyCenter = ctx.menuButtonCY[i] + contentOffsetY; - } else { - float offset = (static_cast(i) - 2.0f) * spacing; - // small per-button offsets to better match original art placement - float extra = 0.0f; - if (i == 0) extra = 15.0f; - if (i == 2) extra = -18.0f; - if (i == 4) extra = -24.0f; - cxCenter = btnX + offset + extra; - } - // Apply group alpha and transient flash to button colors - double aMul = std::clamp(buttonGroupAlpha + buttonFlash * buttonFlashAmount, 0.0, 1.0); - SDL_Color bgCol = buttons[i].bg; - SDL_Color bdCol = buttons[i].border; - bgCol.a = static_cast(std::round(aMul * static_cast(bgCol.a))); - bdCol.a = static_cast(std::round(aMul * static_cast(bdCol.a))); - UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, - buttons[i].label, false, selectedButton == i, - bgCol, bdCol, true, icons[i]); - // no per-button neon outline here; draw group background below instead - } - - // (panel for the top-button draw path is drawn before the buttons so text is on top) + const int hovered = (ctx.hoveredButton ? *ctx.hoveredButton : -1); + const double baseAlpha = 1.0; + // Pulse is encoded as a signed delta so PLAY can dim/brighten while focused. + const double pulseDelta = (buttonPulseAlpha - 1.0); + const double flashDelta = buttonFlash * buttonFlashAmount; + ui::renderBottomMenu(renderer, ctx.pixelFont, menu, hovered, selectedButton, baseAlpha, pulseDelta + flashDelta); } void MenuState::onExit() { @@ -250,6 +211,11 @@ void MenuState::onExit() { void MenuState::handleEvent(const SDL_Event& e) { // Keyboard navigation for menu buttons if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + // When the player uses the keyboard, don't let an old mouse hover keep focus on a button. + if (ctx.hoveredButton) { + *ctx.hoveredButton = -1; + } + auto triggerPlay = [&]() { if (ctx.startPlayTransition) { ctx.startPlayTransition(); @@ -401,14 +367,40 @@ void MenuState::handleEvent(const SDL_Event& e) { // Close help panel helpPanelAnimating = true; helpDirection = -1; return; + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_UP: + case SDL_SCANCODE_DOWN: + // Arrow keys: close help and immediately return to main menu navigation. + helpPanelAnimating = true; helpDirection = -1; + break; case SDL_SCANCODE_PAGEDOWN: - case SDL_SCANCODE_DOWN: { - helpScroll += 40.0; return; - } + helpScroll += 40.0; + return; case SDL_SCANCODE_PAGEUP: - case SDL_SCANCODE_UP: { - helpScroll -= 40.0; if (helpScroll < 0.0) helpScroll = 0.0; return; - } + helpScroll -= 40.0; + if (helpScroll < 0.0) helpScroll = 0.0; + return; + default: + return; + } + } + + // If the inline about HUD is visible and not animating, capture navigation + if (aboutPanelVisible && !aboutPanelAnimating) { + switch (e.key.scancode) { + case SDL_SCANCODE_ESCAPE: + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + aboutPanelAnimating = true; aboutDirection = -1; + return; + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_UP: + case SDL_SCANCODE_DOWN: + aboutPanelAnimating = true; aboutDirection = -1; + break; default: return; } @@ -450,7 +442,7 @@ void MenuState::handleEvent(const SDL_Event& e) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_UP: { - const int total = 5; + const int total = 6; selectedButton = (selectedButton + total - 1) % total; // brief bright flash on navigation buttonFlash = 1.0; @@ -459,7 +451,7 @@ void MenuState::handleEvent(const SDL_Event& e) { case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_DOWN: { - const int total = 5; + const int total = 6; selectedButton = (selectedButton + 1) % total; // brief bright flash on navigation buttonFlash = 1.0; @@ -509,6 +501,16 @@ void MenuState::handleEvent(const SDL_Event& e) { } break; case 4: + // Toggle the inline ABOUT HUD (show/hide) + if (!aboutPanelVisible && !aboutPanelAnimating) { + aboutPanelAnimating = true; + aboutDirection = 1; + } else if (aboutPanelVisible && !aboutPanelAnimating) { + aboutPanelAnimating = true; + aboutDirection = -1; + } + break; + case 5: // Show the inline exit HUD if (!exitPanelVisible && !exitPanelAnimating) { exitPanelAnimating = true; @@ -605,6 +607,21 @@ void MenuState::update(double frameMs) { } } + // Advance about panel animation if active + if (aboutPanelAnimating) { + double delta = (frameMs / aboutTransitionDurationMs) * static_cast(aboutDirection); + aboutTransition += delta; + if (aboutTransition >= 1.0) { + aboutTransition = 1.0; + aboutPanelVisible = true; + aboutPanelAnimating = false; + } else if (aboutTransition <= 0.0) { + aboutTransition = 0.0; + aboutPanelVisible = false; + aboutPanelAnimating = false; + } + } + // Animate level selection highlight position toward the selected cell center if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) { // Recompute same grid geometry used in render to find target center @@ -646,7 +663,7 @@ void MenuState::update(double frameMs) { } } - // Update button group pulsing animation + // Update pulsing animation (used for PLAY emphasis) if (buttonPulseEnabled) { buttonPulseTime += frameMs; double t = (buttonPulseTime * 0.001) * buttonPulseSpeed; // seconds * speed @@ -676,11 +693,14 @@ void MenuState::update(double frameMs) { default: s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5; } - buttonGroupAlpha = buttonPulseMinAlpha + s * (buttonPulseMaxAlpha - buttonPulseMinAlpha); + buttonPulseAlpha = buttonPulseMinAlpha + s * (buttonPulseMaxAlpha - buttonPulseMinAlpha); } else { - buttonGroupAlpha = 1.0; + buttonPulseAlpha = 1.0; } + // Keep the base group alpha stable; pulsing is applied selectively in the renderer. + buttonGroupAlpha = 1.0; + // Update flash decay if (buttonFlash > 0.0) { buttonFlash -= frameMs * buttonFlashDecay; @@ -727,14 +747,18 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi const float moveAmount = 420.0f; // increased so lower score rows slide further up // Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown. - float combinedTransition = static_cast(std::max(std::max(std::max(optionsTransition, levelTransition), exitTransition), helpTransition)); + float combinedTransition = static_cast(std::max( + std::max(std::max(optionsTransition, levelTransition), exitTransition), + std::max(helpTransition, aboutTransition) + )); float eased = combinedTransition * combinedTransition * (3.0f - 2.0f * combinedTransition); // cubic smoothstep float panelDelta = eased * moveAmount; // Draw a larger centered logo above the highscores area, then a small "TOP PLAYER" label - // Move logo a bit lower for better spacing + // Move the whole block slightly up to better match the main screen overlay framing. float menuYOffset = LOGICAL_H * 0.03f; // same offset used for buttons - float topPlayersY = LOGICAL_H * 0.20f + contentOffsetY - panelDelta + menuYOffset; + float scoresYOffset = -LOGICAL_H * 0.05f; + float topPlayersY = LOGICAL_H * 0.20f + contentOffsetY - panelDelta + menuYOffset + scoresYOffset; float scoresStartY = topPlayersY; if (useFont) { // Preferred logo texture (full) if present, otherwise the small logo @@ -1196,7 +1220,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi // Shortcut entries (copied from HelpOverlay) struct ShortcutEntry { const char* combo; const char* description; }; const ShortcutEntry generalShortcuts[] = { - {"H", "Toggle this help overlay"}, + {"F1", "Toggle this help overlay"}, {"ESC", "Back / cancel current popup"}, {"F11 or ALT+ENTER", "Toggle fullscreen"}, {"M", "Mute or unmute music"}, @@ -1211,6 +1235,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi {"DOWN", "Soft drop (faster fall)"}, {"SPACE", "Hard drop / instant lock"}, {"UP", "Rotate clockwise"}, + {"H", "Hold / swap current piece"}, {"X", "Toggle rotation direction used by UP"}, {"P", "Pause or resume"}, {"ESC", "Open exit confirmation"} @@ -1246,18 +1271,58 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi int w=0,h=0; f->measure(entry.description, 0.62f, w, h); cursorY += static_cast(h) + 16.0f; } + + // (rest of help render continues below) // Add a larger gap between sections cursorY += 22.0f; + + // Draw inline ABOUT HUD (no boxed background) — simple main info + if (aboutTransition > 0.0) { + float easedA = static_cast(aboutTransition); + easedA = easedA * easedA * (3.0f - 2.0f * easedA); + const float PW = std::min(520.0f, LOGICAL_W * 0.65f); + const float PH = std::min(320.0f, LOGICAL_H * 0.60f); + float panelBaseX = (LOGICAL_W - PW) * 0.5f + contentOffsetX; + float panelBaseY = (LOGICAL_H - PH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); + float slideAmount = LOGICAL_H * 0.42f; + float panelY = panelBaseY + (1.0f - easedA) * slideAmount; + + FontAtlas* f = ctx.pixelFont ? ctx.pixelFont : ctx.font; + if (f) { + f->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "ABOUT", 1.25f, SDL_Color{255,220,0,255}); + + float x = panelBaseX + 16.0f; + float y = panelY + 52.0f; + const float lineGap = 30.0f; + const SDL_Color textCol{200, 210, 230, 255}; + const SDL_Color keyCol{255, 255, 255, 255}; + + f->draw(renderer, x, y, "SDL3 SPACETRIS", 1.05f, keyCol); y += lineGap; + f->draw(renderer, x, y, "C++20 / SDL3 / SDL3_ttf", 0.80f, textCol); y += lineGap + 6.0f; + + f->draw(renderer, x, y, "GAMEPLAY", 0.85f, SDL_Color{180,200,255,255}); y += lineGap; + f->draw(renderer, x, y, "H Hold / swap current piece", 0.78f, textCol); y += lineGap; + f->draw(renderer, x, y, "SPACE Hard drop", 0.78f, textCol); y += lineGap; + f->draw(renderer, x, y, "P Pause", 0.78f, textCol); y += lineGap + 6.0f; + + f->draw(renderer, x, y, "UI", 0.85f, SDL_Color{180,200,255,255}); y += lineGap; + f->draw(renderer, x, y, "F1 Toggle help overlay", 0.78f, textCol); y += lineGap; + f->draw(renderer, x, y, "ESC Back / exit prompt", 0.78f, textCol); y += lineGap + 10.0f; + + f->draw(renderer, x, y, "PRESS ESC OR ARROW KEYS TO RETURN", 0.75f, SDL_Color{215,220,240,255}); + } + } }; - float leftCursor = panelY + 48.0f - static_cast(helpScroll); - float rightCursor = panelY + 48.0f - static_cast(helpScroll); + const float contentTopY = panelY + 30.0f; + float leftCursor = contentTopY - static_cast(helpScroll); + float rightCursor = contentTopY - static_cast(helpScroll); drawSection(leftX, leftCursor, "GENERAL", generalShortcuts, (int)(sizeof(generalShortcuts)/sizeof(generalShortcuts[0]))); drawSection(leftX, leftCursor, "MENUS", menuShortcuts, (int)(sizeof(menuShortcuts)/sizeof(menuShortcuts[0]))); drawSection(rightX, rightCursor, "GAMEPLAY", gameplayShortcuts, (int)(sizeof(gameplayShortcuts)/sizeof(gameplayShortcuts[0]))); // Ensure helpScroll bounds (simple clamp) - float contentHeight = std::max(leftCursor, rightCursor) - (panelY + 48.0f); + float contentHeight = std::max(leftCursor, rightCursor) - contentTopY; float maxScroll = std::max(0.0f, contentHeight - (PH - 120.0f)); if (helpScroll < 0.0) helpScroll = 0.0; if (helpScroll > maxScroll) helpScroll = maxScroll; diff --git a/src/states/MenuState.h b/src/states/MenuState.h index 32243d9..7522975 100644 --- a/src/states/MenuState.h +++ b/src/states/MenuState.h @@ -17,9 +17,11 @@ public: void renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP); // Show or hide the inline HELP panel (menu-style) void showHelpPanel(bool show); + // Show or hide the inline ABOUT panel (menu-style) + void showAboutPanel(bool show); private: - int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = HELP, 4 = EXIT + int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = HELP, 4 = ABOUT, 5 = EXIT // Button icons (optional - will use text if nullptr) SDL_Texture* playIcon = nullptr; @@ -56,8 +58,9 @@ private: double levelHighlightGlowAlpha = 0.70; // 0..1 base glow alpha int levelHighlightThickness = 3; // inner outline thickness (px) SDL_Color levelHighlightColor = SDL_Color{80, 200, 255, 200}; - // Button group pulsing/fade parameters (applies to all four main buttons) - double buttonGroupAlpha = 1.0; // current computed alpha (0..1) + // Button pulsing/fade parameters (used for PLAY emphasis) + double buttonGroupAlpha = 1.0; // base alpha for the whole group (kept stable) + double buttonPulseAlpha = 1.0; // pulsing alpha (0..1), applied to PLAY only double buttonPulseTime = 0.0; // accumulator in ms bool buttonPulseEnabled = true; // enable/disable pulsing double buttonPulseSpeed = 1.0; // multiplier for pulse frequency @@ -84,4 +87,11 @@ private: double helpTransitionDurationMs = 360.0; int helpDirection = 1; // 1 show, -1 hide double helpScroll = 0.0; // vertical scroll offset for content + + // About submenu (inline HUD like Help) + bool aboutPanelVisible = false; + bool aboutPanelAnimating = false; + double aboutTransition = 0.0; // 0..1 + double aboutTransitionDurationMs = 360.0; + int aboutDirection = 1; // 1 show, -1 hide }; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 2ea5417..f055ffb 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -118,6 +118,12 @@ void PlayingState::handleEvent(const SDL_Event& e) { // Tetris controls (only when not paused) if (!ctx.game->isPaused()) { + // Hold / swap current piece (H) + if (e.key.scancode == SDL_SCANCODE_H) { + ctx.game->holdCurrent(); + return; + } + // Rotation (still event-based for precise timing) if (e.key.scancode == SDL_SCANCODE_UP) { // Use user setting to determine whether UP rotates clockwise @@ -232,6 +238,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.statisticsPanelTex, ctx.scorePanelTex, ctx.nextPanelTex, + ctx.holdPanelTex, 1200.0f, // LOGICAL_W 1000.0f, // LOGICAL_H logicalScale, @@ -319,6 +326,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.statisticsPanelTex, ctx.scorePanelTex, ctx.nextPanelTex, + ctx.holdPanelTex, 1200.0f, 1000.0f, logicalScale, diff --git a/src/states/State.h b/src/states/State.h index 6aa99e6..36a7a95 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -43,6 +43,7 @@ struct StateContext { SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr; + SDL_Texture* holdPanelTex = nullptr; // Background for the HOLD preview SDL_Texture* mainScreenTex = nullptr; int mainScreenW = 0; int mainScreenH = 0; diff --git a/src/ui/BottomMenu.cpp b/src/ui/BottomMenu.cpp new file mode 100644 index 0000000..1910ca4 --- /dev/null +++ b/src/ui/BottomMenu.cpp @@ -0,0 +1,129 @@ +#include "ui/BottomMenu.h" + +#include +#include +#include + +#include "graphics/renderers/UIRenderer.h" +#include "graphics/Font.h" + +namespace ui { + +static bool pointInRect(const SDL_FRect& r, float x, float y) { + return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h); +} + +BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) { + BottomMenu menu{}; + + auto rects = computeMenuButtonRects(params); + + char levelBtnText[32]; + std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); + + menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false }; + menu.buttons[1] = Button{ BottomMenuItem::Level, rects[1], levelBtnText, true }; + menu.buttons[2] = Button{ BottomMenuItem::Options, rects[2], "OPTIONS", true }; + menu.buttons[3] = Button{ BottomMenuItem::Help, rects[3], "HELP", true }; + menu.buttons[4] = Button{ BottomMenuItem::About, rects[4], "ABOUT", true }; + menu.buttons[5] = Button{ BottomMenuItem::Exit, rects[5], "EXIT", true }; + + return menu; +} + +void renderBottomMenu(SDL_Renderer* renderer, + FontAtlas* font, + const BottomMenu& menu, + int hoveredIndex, + int selectedIndex, + double baseAlphaMul, + double flashAddMul) { + if (!renderer || !font) return; + + const double baseMul = std::clamp(baseAlphaMul, 0.0, 1.0); + const double flashMul = flashAddMul; + + const int focusIndex = (hoveredIndex != -1) ? hoveredIndex : selectedIndex; + + for (int i = 0; i < MENU_BTN_COUNT; ++i) { + const Button& b = menu.buttons[i]; + const SDL_FRect& r = b.rect; + + float cx = r.x + r.w * 0.5f; + float cy = r.y + r.h * 0.5f; + + bool isHovered = (hoveredIndex == i); + bool isSelected = (selectedIndex == i); + + // Requested behavior: flash only the PLAY button, and only when it's the active/focused item. + const bool playIsActive = (i == 0) && (focusIndex == 0); + const double aMul = std::clamp(baseMul + (playIsActive ? flashMul : 0.0), 0.0, 1.0); + + if (!b.textOnly) { + SDL_Color bgCol{ 18, 22, 28, static_cast(std::round(180.0 * aMul)) }; + SDL_Color bdCol{ 255, 200, 70, static_cast(std::round(220.0 * aMul)) }; + UIRenderer::drawButton(renderer, font, cx, cy, r.w, r.h, + b.label, isHovered, isSelected, + bgCol, bdCol, false, nullptr); + } else { + SDL_Color bgCol{ 20, 30, 42, static_cast(std::round(160.0 * aMul)) }; + SDL_Color bdCol{ 120, 220, 255, static_cast(std::round(200.0 * aMul)) }; + UIRenderer::drawButton(renderer, font, cx, cy, r.w, r.h, + b.label, isHovered, isSelected, + bgCol, bdCol, true, nullptr); + } + } + + // '+' separators between the bottom HUD buttons (indices 1..last) + { + SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE; + SDL_GetRenderDrawBlendMode(renderer, &prevBlend); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast(std::round(180.0 * baseMul))); + + const int firstSmall = 1; + const int lastSmall = MENU_BTN_COUNT - 1; + float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f; + for (int i = firstSmall; i < lastSmall; ++i) { + float x = (menu.buttons[i].rect.x + menu.buttons[i].rect.w + menu.buttons[i + 1].rect.x) * 0.5f; + SDL_RenderLine(renderer, x - 4.0f, y, x + 4.0f, y); + SDL_RenderLine(renderer, x, y - 4.0f, x, y + 4.0f); + } + + SDL_SetRenderDrawBlendMode(renderer, prevBlend); + } +} + +BottomMenuInputResult handleBottomMenuInput(const MenuLayoutParams& params, + const SDL_Event& e, + float x, + float y, + int prevHoveredIndex, + bool inputEnabled) { + BottomMenuInputResult result{}; + result.hoveredIndex = prevHoveredIndex; + + if (!inputEnabled) { + return result; + } + + if (e.type == SDL_EVENT_MOUSE_MOTION) { + result.hoveredIndex = hitTestMenuButtons(params, x, y); + return result; + } + + if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) { + auto rects = computeMenuButtonRects(params); + for (int i = 0; i < MENU_BTN_COUNT; ++i) { + if (pointInRect(rects[i], x, y)) { + result.activated = static_cast(i); + result.hoveredIndex = i; + break; + } + } + } + + return result; +} + +} // namespace ui diff --git a/src/ui/BottomMenu.h b/src/ui/BottomMenu.h new file mode 100644 index 0000000..3f54fbe --- /dev/null +++ b/src/ui/BottomMenu.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +#include + +#include "ui/MenuLayout.h" +#include "ui/UIConstants.h" + +struct FontAtlas; + +namespace ui { + +enum class BottomMenuItem : int { + Play = 0, + Level = 1, + Options = 2, + Help = 3, + About = 4, + Exit = 5, +}; + +struct Button { + BottomMenuItem item{}; + SDL_FRect rect{}; + std::string label; + bool textOnly = true; +}; + +struct BottomMenu { + std::array buttons{}; +}; + +BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel); + +// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives. +// hoveredIndex: -1..5 +// selectedIndex: 0..5 (keyboard selection) +// alphaMul: 0..1 (overall group alpha) +void renderBottomMenu(SDL_Renderer* renderer, + FontAtlas* font, + const BottomMenu& menu, + int hoveredIndex, + int selectedIndex, + double baseAlphaMul, + double flashAddMul); + +struct BottomMenuInputResult { + int hoveredIndex = -1; + std::optional activated; +}; + +// Interprets mouse motion/button input for the bottom menu. +// Expects x/y in the same logical coordinate space used by MenuLayout (the current main loop already provides this). +BottomMenuInputResult handleBottomMenuInput(const MenuLayoutParams& params, + const SDL_Event& e, + float x, + float y, + int prevHoveredIndex, + bool inputEnabled); + +} // namespace ui diff --git a/src/ui/MenuLayout.cpp b/src/ui/MenuLayout.cpp new file mode 100644 index 0000000..5cf6560 --- /dev/null +++ b/src/ui/MenuLayout.cpp @@ -0,0 +1,77 @@ +#include "ui/MenuLayout.h" +#include "ui/UIConstants.h" +#include +#include + +namespace ui { + +std::array computeMenuButtonRects(const MenuLayoutParams& p) { + const float LOGICAL_W = static_cast(p.logicalW); + const float LOGICAL_H = static_cast(p.logicalH); + float contentOffsetX = (p.winW - LOGICAL_W * p.logicalScale) * 0.5f / p.logicalScale; + float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale; + + // Cockpit HUD layout (matches main_screen art): + // - A big centered PLAY button + // - A second row of 5 smaller buttons: LEVEL / OPTIONS / HELP / ABOUT / EXIT + const float marginX = std::max(24.0f, LOGICAL_W * 0.03f); + const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f); + const float availableW = std::max(120.0f, LOGICAL_W - marginX * 2.0f); + + float playW = std::min(230.0f, availableW * 0.27f); + float playH = 35.0f; + float smallW = std::min(220.0f, availableW * 0.23f); + float smallH = 34.0f; + float smallSpacing = 28.0f; + + // Scale down for narrow windows so nothing goes offscreen. + const int smallCount = MENU_BTN_COUNT - 1; + float smallTotal = smallW * static_cast(smallCount) + smallSpacing * static_cast(smallCount - 1); + if (smallTotal > availableW) { + float s = availableW / smallTotal; + smallW *= s; + smallH *= s; + smallSpacing *= s; + playW = std::min(playW, availableW); + playH *= std::max(0.75f, s); + } + + float centerX = LOGICAL_W * 0.5f + contentOffsetX; + float bottomY = LOGICAL_H + contentOffsetY - marginBottom; + float smallCY = bottomY - smallH * 0.5f; + // Extra breathing room between PLAY and the bottom row (requested). + const float rowGap = 34.0f; + float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f; + + std::array rects{}; + rects[0] = SDL_FRect{ centerX - playW * 0.5f, playCY - playH * 0.5f, playW, playH }; + + float rowW = smallW * static_cast(smallCount) + smallSpacing * static_cast(smallCount - 1); + float left = centerX - rowW * 0.5f; + float minLeft = contentOffsetX + marginX; + float maxRight = contentOffsetX + LOGICAL_W - marginX; + if (left < minLeft) left = minLeft; + if (left + rowW > maxRight) left = std::max(minLeft, maxRight - rowW); + + for (int i = 0; i < smallCount; ++i) { + float x = left + i * (smallW + smallSpacing); + rects[i + 1] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH }; + } + return rects; +} + +int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY) { + auto rects = computeMenuButtonRects(p); + for (int i = 0; i < MENU_BTN_COUNT; ++i) { + const auto &r = rects[i]; + if (localX >= r.x && localX <= r.x + r.w && localY >= r.y && localY <= r.y + r.h) + return i; + } + return -1; +} + +SDL_FRect settingsButtonRect(const MenuLayoutParams& p) { + return SDL_FRect{SETTINGS_BTN_X, SETTINGS_BTN_Y, SETTINGS_BTN_W, SETTINGS_BTN_H}; +} + +} // namespace ui diff --git a/src/ui/MenuLayout.h b/src/ui/MenuLayout.h new file mode 100644 index 0000000..d185860 --- /dev/null +++ b/src/ui/MenuLayout.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include "ui/UIConstants.h" +#include + +namespace ui { + +struct MenuLayoutParams { + int logicalW; + int logicalH; + int winW; + int winH; + float logicalScale; +}; + +// Compute menu button rects in logical coordinates (content-local) +std::array computeMenuButtonRects(const MenuLayoutParams& p); + +// Hit test a point given in logical content-local coordinates against menu buttons +// Returns index 0..4 or -1 if none +int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY); + +// Return settings button rect (logical coords) +SDL_FRect settingsButtonRect(const MenuLayoutParams& p); + +} // namespace ui diff --git a/src/ui/MenuWrappers.cpp b/src/ui/MenuWrappers.cpp index cb23a98..ceeb95c 100644 --- a/src/ui/MenuWrappers.cpp +++ b/src/ui/MenuWrappers.cpp @@ -2,6 +2,7 @@ #include "MenuWrappers.h" #include "../core/GlobalState.h" #include "../graphics/Font.h" +#include "app/Fireworks.h" #include using namespace Globals; @@ -13,19 +14,19 @@ static void drawRect(SDL_Renderer* renderer, float x, float y, float w, float h, } void menu_drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) { - GlobalState::instance().drawFireworks(renderer, blocksTex); + AppFireworks::draw(renderer, blocksTex); } void menu_updateFireworks(double frameMs) { - GlobalState::instance().updateFireworks(frameMs); + AppFireworks::update(frameMs); } double menu_getLogoAnimCounter() { - return GlobalState::instance().logoAnimCounter; + return AppFireworks::getLogoAnimCounter(); } int menu_getHoveredButton() { - return GlobalState::instance().hoveredButton; + return AppFireworks::getHoveredButton(); } void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, diff --git a/src/ui/UIConstants.h b/src/ui/UIConstants.h new file mode 100644 index 0000000..eb7b5c4 --- /dev/null +++ b/src/ui/UIConstants.h @@ -0,0 +1,18 @@ +#pragma once + +static constexpr int MENU_BTN_COUNT = 6; +static constexpr float MENU_SMALL_THRESHOLD = 700.0f; +static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f; +static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W +static constexpr float MENU_BTN_HEIGHT_LARGE = 70.0f; +static constexpr float MENU_BTN_HEIGHT_SMALL = 60.0f; +static constexpr float MENU_BTN_Y_OFFSET = 58.0f; // matches MenuState offset; slightly lower for windowed visibility +static constexpr float MENU_BTN_SPACING_FACTOR_SMALL = 1.15f; +static constexpr float MENU_BTN_SPACING_FACTOR_LARGE = 1.05f; +static constexpr float MENU_BTN_CENTER = (MENU_BTN_COUNT - 1) / 2.0f; +// Settings button metrics +static constexpr float SETTINGS_BTN_OFFSET_X = 60.0f; +static constexpr float SETTINGS_BTN_X = 1200 - SETTINGS_BTN_OFFSET_X; // LOGICAL_W is 1200 +static constexpr float SETTINGS_BTN_Y = 10.0f; +static constexpr float SETTINGS_BTN_W = 50.0f; +static constexpr float SETTINGS_BTN_H = 30.0f;