Compare commits

..

20 Commits

Author SHA1 Message Date
fd29ae271e Merge branch 'feature/ReafctoringGame' into develop 2025-12-20 12:13:58 +01:00
737dc71d8c seperate assets as constants 2025-12-20 12:05:40 +01:00
8a4dc2771d Refactoring game render 2025-12-20 11:31:02 +01:00
212dd4c404 refactor hold element drawing 2025-12-20 11:22:18 +01:00
a520de6c1f Added hold block and minor fixes 2025-12-20 11:10:23 +01:00
38dbc17ace refactor main game loop 2025-12-19 20:07:48 +01:00
783c12790d refactor main.cpp 2025-12-19 19:25:56 +01:00
64fce596ce fillRect moved 2025-12-19 18:22:26 +01:00
adf418dff9 refactored some functions from main.cpp 2025-12-19 18:16:17 +01:00
fe0cd289e2 fix 2025-12-19 16:48:26 +01:00
989b98002c new background image 2025-12-18 07:20:20 +01:00
0ab7121c5b Hold block added 2025-12-17 21:05:32 +01:00
492abc09bc fixed menu in help options 2025-12-17 20:35:39 +01:00
fe6c5e3c8a Updated bottom menu 2025-12-17 20:12:06 +01:00
122de2b36f Bottom menu reorganized 2025-12-17 19:23:16 +01:00
a671825502 fixed menu 2025-12-17 18:55:55 +01:00
cecf5cf68e fixed main screen 2025-12-17 18:13:59 +01:00
3264672be0 Fixed gameplay 2025-12-16 18:51:23 +01:00
29c1d6b745 fixed loader and music playback 2025-12-16 14:18:15 +01:00
81586aa768 fixed progress bar 2025-12-16 12:09:33 +01:00
49 changed files with 3812 additions and 2331 deletions

View File

@ -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}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

View File

@ -14,7 +14,7 @@ SmoothScroll=1
UpRotateClockwise=0
[Player]
Name=PLAYER
Name=GREGOR
[Debug]
Enabled=1

139
src/app/AssetLoader.cpp Normal file
View File

@ -0,0 +1,139 @@
#include "app/AssetLoader.h"
#include <SDL3_image/SDL_image.h>
#include <algorithm>
AssetLoader::AssetLoader() = default;
AssetLoader::~AssetLoader() {
shutdown();
}
void AssetLoader::init(SDL_Renderer* renderer) {
m_renderer = renderer;
}
void AssetLoader::shutdown() {
// Destroy textures
{
std::lock_guard<std::mutex> 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<std::mutex> lk(m_queueMutex);
m_queue.clear();
}
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lk(m_queueMutex);
if (m_queue.empty()) return true;
path = m_queue.front();
m_queue.erase(m_queue.begin());
}
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lk(m_errorsMutex);
m_errors.push_back(std::string("CreateTexture failed: ") + fullPath);
} else {
std::lock_guard<std::mutex> 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<std::mutex> lk(m_currentLoadingMutex);
m_currentLoading.clear();
}
// Return true when no more queued tasks
{
std::lock_guard<std::mutex> lk(m_queueMutex);
return m_queue.empty();
}
}
void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) {
if (!texture) {
return;
}
std::lock_guard<std::mutex> 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<float>(loaded) / static_cast<float>(total);
}
std::vector<std::string> AssetLoader::getAndClearErrors() {
std::lock_guard<std::mutex> lk(m_errorsMutex);
std::vector<std::string> out = m_errors;
m_errors.clear();
return out;
}
SDL_Texture* AssetLoader::getTexture(const std::string& path) const {
std::lock_guard<std::mutex> 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<std::mutex> lk(m_currentLoadingMutex);
return m_currentLoading;
}

68
src/app/AssetLoader.h Normal file
View File

@ -0,0 +1,68 @@
#pragma once
#include <SDL3/SDL.h>
#include <string>
#include <vector>
#include <mutex>
#include <atomic>
#include <unordered_map>
// 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<std::string> 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<std::string> m_queue;
mutable std::mutex m_queueMutex;
std::unordered_map<std::string, SDL_Texture*> m_textures;
mutable std::mutex m_texturesMutex;
std::vector<std::string> m_errors;
mutable std::mutex m_errorsMutex;
std::atomic<int> m_totalTasks{0};
std::atomic<int> m_loadedTasks{0};
std::string m_currentLoading;
mutable std::mutex m_currentLoadingMutex;
};

View File

@ -0,0 +1,165 @@
#include "app/BackgroundManager.h"
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#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<Uint8>(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f);
SDL_SetTextureAlphaMod(tex, alpha);
SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, &center, SDL_FLIP_NONE);
SDL_SetTextureAlphaMod(tex, 255);
}
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;
}

View File

@ -0,0 +1,18 @@
#pragma once
#include <SDL3/SDL.h>
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;
};

147
src/app/Fireworks.cpp Normal file
View File

@ -0,0 +1,147 @@
#include "app/Fireworks.h"
#include <SDL3/SDL.h>
#include <vector>
#include <cstdlib>
#include <cmath>
#include <algorithm>
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<BlockParticle> particles;
TetrisFirework(float x, float y) {
int particleCount = 30 + rand() % 25;
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();
}
};
static std::vector<TetrisFirework> 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<Uint8>(std::clamp(120.0f + heat * (255.0f - 120.0f), float(minG), 255.0f));
Uint8 b = static_cast<Uint8>(std::clamp(40.0f + (1.0f - heat) * 60.0f, float(minB), 255.0f));
Uint8 a = static_cast<Uint8>(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

9
src/app/Fireworks.h Normal file
View File

@ -0,0 +1,9 @@
#pragma once
#include <SDL3/SDL.h>
namespace AppFireworks {
void draw(SDL_Renderer* renderer, SDL_Texture* tex);
void update(double frameMs);
double getLogoAnimCounter();
int getHoveredButton();
}

1737
src/app/TetrisApp.cpp Normal file

File diff suppressed because it is too large Load Diff

29
src/app/TetrisApp.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#include <memory>
// 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> impl_;
};

91
src/app/TextureLoader.cpp Normal file
View File

@ -0,0 +1,91 @@
#include "app/TextureLoader.h"
#include <SDL3_image/SDL_image.h>
#include <algorithm>
#include <mutex>
#include <sstream>
#include "utils/ImagePathResolver.h"
TextureLoader::TextureLoader(
std::atomic<int>& loadedTasks,
std::string& currentLoadingFile,
std::mutex& currentLoadingMutex,
std::vector<std::string>& assetLoadErrors,
std::mutex& assetLoadErrorsMutex)
: loadedTasks_(loadedTasks)
, currentLoadingFile_(currentLoadingFile)
, currentLoadingMutex_(currentLoadingMutex)
, assetLoadErrors_(assetLoadErrors)
, assetLoadErrorsMutex_(assetLoadErrorsMutex)
{
}
void TextureLoader::setCurrentLoadingFile(const std::string& filename) {
std::lock_guard<std::mutex> lk(currentLoadingMutex_);
currentLoadingFile_ = filename;
}
void TextureLoader::clearCurrentLoadingFile() {
std::lock_guard<std::mutex> lk(currentLoadingMutex_);
currentLoadingFile_.clear();
}
void TextureLoader::recordAssetLoadError(const std::string& message) {
std::lock_guard<std::mutex> 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;
}

31
src/app/TextureLoader.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include <SDL3/SDL.h>
#include <atomic>
#include <mutex>
#include <string>
#include <vector>
class TextureLoader {
public:
TextureLoader(
std::atomic<int>& loadedTasks,
std::string& currentLoadingFile,
std::mutex& currentLoadingMutex,
std::vector<std::string>& assetLoadErrors,
std::mutex& assetLoadErrorsMutex);
SDL_Texture* loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr);
private:
std::atomic<int>& loadedTasks_;
std::string& currentLoadingFile_;
std::mutex& currentLoadingMutex_;
std::vector<std::string>& assetLoadErrors_;
std::mutex& assetLoadErrorsMutex_;
void setCurrentLoadingFile(const std::string& filename);
void clearCurrentLoadingFile();
void recordAssetLoadError(const std::string& message);
};

View File

@ -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());

View File

@ -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

View File

@ -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,

View File

@ -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<PieceType>(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<PieceType>(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<int>(game->elapsed());
int mins = totalSecs / 60;
int secs = totalSecs % 60;
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255});
}
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<int>(gridX), static_cast<int>(gridY), static_cast<int>(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<int>(game->elapsed());
int mins = totalSecs / 60;
int secs = totalSecs % 60;
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255});
// 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<PieceType>(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<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
}
}
// Pause overlay (suppressed when requested, e.g., countdown)

View File

@ -24,6 +24,7 @@ public:
SDL_Texture* statisticsPanelTex,
SDL_Texture* scorePanelTex,
SDL_Texture* nextPanelTex,
SDL_Texture* holdPanelTex,
float logicalW,
float logicalH,
float logicalScale,

View File

@ -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 24 rows.
- Mid levels: bottom 610 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 19: Normal only
- Levels 1019: add Armored (small %)
- Levels 2059: add Falling (increasing %)
- Levels 60100: 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
---

View File

@ -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;

View File

@ -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;

View File

@ -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<float>::max();
float statsContentBottom = std::numeric_limits<float>::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<PieceType>(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<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
}
}
// Pause overlay logic moved to renderPauseOverlay

View File

@ -24,6 +24,7 @@ public:
SDL_Texture* statisticsPanelTex,
SDL_Texture* scorePanelTex,
SDL_Texture* nextPanelTex,
SDL_Texture* holdPanelTex,
float logicalW,
float logicalH,
float logicalScale,

View File

@ -0,0 +1,16 @@
#pragma once
#include <SDL3/SDL.h>
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

View File

@ -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<Uint8>(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<Uint8>(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<float>(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<float>(textH)) * 0.5f + textNudge;
// Vertically center text within the button.
float ty = y + (h - static_cast<float>(textH)) * 0.5f;
// Choose text color based on selection state
SDL_Color textColor = {255, 255, 255, 255}; // Default white

View File

@ -34,7 +34,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
if (!renderer) return;
const std::array<ShortcutEntry, 5> 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<ShortcutEntry, 7> gameplayShortcuts{{
const std::array<ShortcutEntry, 8> 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);

File diff suppressed because it is too large Load Diff

View File

@ -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/";
}

View File

@ -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<std::string> LoadingManager::getAndClearErrors() {
if (!m_loader) return {};
return m_loader->getAndClearErrors();
}
std::string LoadingManager::getCurrentLoading() const {
if (!m_loader) return {};
return m_loader->getCurrentLoading();
}

View File

@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <vector>
#include <memory>
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<std::string> getAndClearErrors();
// Current path being loaded (or empty)
std::string getCurrentLoading() const;
private:
AssetLoader* m_loader = nullptr;
bool m_started = false;
};

View File

@ -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 <SDL3_image/SDL_image.h>
// 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<MenuButtonDef,5> 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<int>(LOGICAL_W),
static_cast<int>(LOGICAL_H),
logicalVP.w,
logicalVP.h,
logicalScale
};
std::array<SDL_Texture*,5> 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<float>(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<Uint8>(std::round(aMul * static_cast<double>(bgCol.a)));
bdCol.a = static_cast<Uint8>(std::round(aMul * static_cast<double>(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<double>(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<float>(std::max(std::max(std::max(optionsTransition, levelTransition), exitTransition), helpTransition));
float combinedTransition = static_cast<float>(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<float>(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<float>(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<float>(helpScroll);
float rightCursor = panelY + 48.0f - static_cast<float>(helpScroll);
const float contentTopY = panelY + 30.0f;
float leftCursor = contentTopY - static_cast<float>(helpScroll);
float rightCursor = contentTopY - static_cast<float>(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;

View File

@ -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
};

View File

@ -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,

View File

@ -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;

129
src/ui/BottomMenu.cpp Normal file
View File

@ -0,0 +1,129 @@
#include "ui/BottomMenu.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#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<Uint8>(std::round(180.0 * aMul)) };
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(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<Uint8>(std::round(160.0 * aMul)) };
SDL_Color bdCol{ 120, 220, 255, static_cast<Uint8>(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<Uint8>(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<BottomMenuItem>(i);
result.hoveredIndex = i;
break;
}
}
}
return result;
}
} // namespace ui

64
src/ui/BottomMenu.h Normal file
View File

@ -0,0 +1,64 @@
#pragma once
#include <array>
#include <optional>
#include <string>
#include <SDL3/SDL.h>
#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<Button, MENU_BTN_COUNT> 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<BottomMenuItem> 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

77
src/ui/MenuLayout.cpp Normal file
View File

@ -0,0 +1,77 @@
#include "ui/MenuLayout.h"
#include "ui/UIConstants.h"
#include <cmath>
#include <array>
namespace ui {
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p) {
const float LOGICAL_W = static_cast<float>(p.logicalW);
const float LOGICAL_H = static_cast<float>(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<float>(smallCount) + smallSpacing * static_cast<float>(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<SDL_FRect, MENU_BTN_COUNT> rects{};
rects[0] = SDL_FRect{ centerX - playW * 0.5f, playCY - playH * 0.5f, playW, playH };
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(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

26
src/ui/MenuLayout.h Normal file
View File

@ -0,0 +1,26 @@
#pragma once
#include <array>
#include "ui/UIConstants.h"
#include <SDL3/SDL.h>
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<SDL_FRect, MENU_BTN_COUNT> 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

View File

@ -2,6 +2,7 @@
#include "MenuWrappers.h"
#include "../core/GlobalState.h"
#include "../graphics/Font.h"
#include "app/Fireworks.h"
#include <SDL3/SDL.h>
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,

18
src/ui/UIConstants.h Normal file
View File

@ -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;