Merge branch 'feature/ReafctoringGame' into develop
@ -31,6 +31,7 @@ find_package(nlohmann_json CONFIG REQUIRED)
|
|||||||
|
|
||||||
set(TETRIS_SOURCES
|
set(TETRIS_SOURCES
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/app/TetrisApp.cpp
|
||||||
src/gameplay/core/Game.cpp
|
src/gameplay/core/Game.cpp
|
||||||
src/core/GravityManager.cpp
|
src/core/GravityManager.cpp
|
||||||
src/core/state/StateManager.cpp
|
src/core/state/StateManager.cpp
|
||||||
@ -52,6 +53,13 @@ set(TETRIS_SOURCES
|
|||||||
src/audio/Audio.cpp
|
src/audio/Audio.cpp
|
||||||
src/gameplay/effects/LineEffect.cpp
|
src/gameplay/effects/LineEffect.cpp
|
||||||
src/audio/SoundEffect.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)
|
# State implementations (new)
|
||||||
src/states/LoadingState.cpp
|
src/states/LoadingState.cpp
|
||||||
src/states/MenuState.cpp
|
src/states/MenuState.cpp
|
||||||
@ -60,6 +68,7 @@ set(TETRIS_SOURCES
|
|||||||
src/states/PlayingState.cpp
|
src/states/PlayingState.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if(APPLE)
|
if(APPLE)
|
||||||
set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns")
|
set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns")
|
||||||
if(EXISTS "${APP_ICON}")
|
if(EXISTS "${APP_ICON}")
|
||||||
|
|||||||
BIN
assets/images/blocks90px_002.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/images/blocks90px_003.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/images/hold_panel.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/main_screen_old.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
@ -14,7 +14,7 @@ SmoothScroll=1
|
|||||||
UpRotateClockwise=0
|
UpRotateClockwise=0
|
||||||
|
|
||||||
[Player]
|
[Player]
|
||||||
Name=PLAYER
|
Name=GREGOR
|
||||||
|
|
||||||
[Debug]
|
[Debug]
|
||||||
Enabled=1
|
Enabled=1
|
||||||
|
|||||||
139
src/app/AssetLoader.cpp
Normal 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
@ -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;
|
||||||
|
};
|
||||||
165
src/app/BackgroundManager.cpp
Normal 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, ¢er, SDL_FLIP_NONE);
|
||||||
|
SDL_SetTextureAlphaMod(tex, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackgroundManager::render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs) {
|
||||||
|
if (!renderer) return;
|
||||||
|
SDL_FRect fullRect{0.f,0.f,(float)winW,(float)winH};
|
||||||
|
float duration = std::max(1.0f, impl->phaseDurationMs);
|
||||||
|
float progress = (impl->phase == Impl::Phase::Idle) ? 0.0f : std::clamp(impl->phaseElapsedMs / duration, 0.0f, 1.0f);
|
||||||
|
const float seconds = motionClockMs * 0.001f;
|
||||||
|
|
||||||
|
if (impl->phase == Impl::Phase::ZoomOut) {
|
||||||
|
float scale = 1.0f + progress * 0.15f;
|
||||||
|
if (impl->currentTex) {
|
||||||
|
renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f));
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0,0,0, Uint8(progress * 200.0f));
|
||||||
|
SDL_RenderFillRect(renderer, &fullRect);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
}
|
||||||
|
} else if (impl->phase == Impl::Phase::ZoomIn) {
|
||||||
|
float scale = 1.10f - progress * 0.10f;
|
||||||
|
Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f);
|
||||||
|
if (impl->currentTex) {
|
||||||
|
renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (impl->currentTex) {
|
||||||
|
renderDynamic(renderer, impl->currentTex, winW, winH, 1.02f, motionClockMs, 1.0f);
|
||||||
|
float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f));
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 5,12,28, Uint8(pulse * 90.0f));
|
||||||
|
SDL_RenderFillRect(renderer, &fullRect);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
} else if (impl->nextTex) {
|
||||||
|
renderDynamic(renderer, impl->nextTex, winW, winH, 1.02f, motionClockMs, 1.0f);
|
||||||
|
} else {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0,0,0,255);
|
||||||
|
SDL_RenderFillRect(renderer, &fullRect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackgroundManager::reset() {
|
||||||
|
destroyTex(impl->currentTex);
|
||||||
|
destroyTex(impl->nextTex);
|
||||||
|
impl->currentLevel = -1;
|
||||||
|
impl->queuedLevel = -1;
|
||||||
|
impl->phaseElapsedMs = 0.0f;
|
||||||
|
impl->phaseDurationMs = 0.0f;
|
||||||
|
impl->phase = Impl::Phase::Idle;
|
||||||
|
}
|
||||||
18
src/app/BackgroundManager.h
Normal 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
@ -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
@ -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
29
src/app/TetrisApp.h
Normal 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
@ -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
@ -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);
|
||||||
|
};
|
||||||
@ -137,6 +137,11 @@ void Audio::shuffle(){
|
|||||||
|
|
||||||
bool Audio::ensureStream(){
|
bool Audio::ensureStream(){
|
||||||
if(audioStream) return true;
|
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);
|
audioStream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &outSpec, &Audio::streamCallback, this);
|
||||||
if(!audioStream){
|
if(!audioStream){
|
||||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] SDL_OpenAudioDeviceStream failed: %s", SDL_GetError());
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] SDL_OpenAudioDeviceStream failed: %s", SDL_GetError());
|
||||||
|
|||||||
@ -14,7 +14,7 @@ namespace Config {
|
|||||||
namespace Window {
|
namespace Window {
|
||||||
constexpr int DEFAULT_WIDTH = 1200;
|
constexpr int DEFAULT_WIDTH = 1200;
|
||||||
constexpr int DEFAULT_HEIGHT = 1000;
|
constexpr int DEFAULT_HEIGHT = 1000;
|
||||||
constexpr const char* DEFAULT_TITLE = "Tetris (SDL3)";
|
constexpr const char* DEFAULT_TITLE = "SpaceTris (SDL3)";
|
||||||
constexpr bool DEFAULT_VSYNC = true;
|
constexpr bool DEFAULT_VSYNC = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +130,7 @@ namespace Config {
|
|||||||
constexpr const char* LOGO_BMP = "assets/images/logo.bmp";
|
constexpr const char* LOGO_BMP = "assets/images/logo.bmp";
|
||||||
constexpr const char* LOGO_SMALL_BMP = "assets/images/logo_small.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* 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
|
// Audio settings
|
||||||
|
|||||||
@ -328,6 +328,26 @@ bool ApplicationManager::initializeManagers() {
|
|||||||
|
|
||||||
// Global hotkeys (handled across all states)
|
// Global hotkeys (handled across all states)
|
||||||
if (pressed) {
|
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)
|
// Toggle fullscreen on F, F11 or Alt+Enter (or Alt+KP_Enter)
|
||||||
if (sc == SDL_SCANCODE_F || sc == SDL_SCANCODE_F11 ||
|
if (sc == SDL_SCANCODE_F || sc == SDL_SCANCODE_F11 ||
|
||||||
((sc == SDL_SCANCODE_RETURN || sc == SDL_SCANCODE_RETURN2 || sc == SDL_SCANCODE_KP_ENTER) &&
|
((sc == SDL_SCANCODE_RETURN || sc == SDL_SCANCODE_RETURN2 || sc == SDL_SCANCODE_KP_ENTER) &&
|
||||||
@ -362,8 +382,9 @@ bool ApplicationManager::initializeManagers() {
|
|||||||
consume = true;
|
consume = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!consume && sc == SDL_SCANCODE_H) {
|
if (!consume && (sc == SDL_SCANCODE_F1)) {
|
||||||
AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading;
|
AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading;
|
||||||
|
// F1 is global (except Loading).
|
||||||
if (currentState != AppState::Loading) {
|
if (currentState != AppState::Loading) {
|
||||||
m_showHelpOverlay = !m_showHelpOverlay;
|
m_showHelpOverlay = !m_showHelpOverlay;
|
||||||
if (currentState == AppState::Playing && m_game) {
|
if (currentState == AppState::Playing && m_game) {
|
||||||
@ -1144,6 +1165,7 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_stateContext.statisticsPanelTex,
|
m_stateContext.statisticsPanelTex,
|
||||||
m_stateContext.scorePanelTex,
|
m_stateContext.scorePanelTex,
|
||||||
m_stateContext.nextPanelTex,
|
m_stateContext.nextPanelTex,
|
||||||
|
m_stateContext.holdPanelTex,
|
||||||
LOGICAL_W,
|
LOGICAL_W,
|
||||||
LOGICAL_H,
|
LOGICAL_H,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
|
|||||||
@ -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(
|
void GameRenderer::renderPlayingState(
|
||||||
SDL_Renderer* renderer,
|
SDL_Renderer* renderer,
|
||||||
Game* game,
|
Game* game,
|
||||||
@ -125,6 +288,7 @@ void GameRenderer::renderPlayingState(
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
@ -164,64 +328,8 @@ void GameRenderer::renderPlayingState(
|
|||||||
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;
|
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;
|
||||||
|
|
||||||
const float maxBlockSizeW = availableWidth / Game::COLS;
|
const float maxBlockSizeW = availableWidth / Game::COLS;
|
||||||
const float maxBlockSizeH = availableHeight / Game::ROWS;
|
// Draw hold panel via helper
|
||||||
float previewY = rowTop - 4.0f;
|
drawHoldPanel(renderer, game, pixelFont, blocksTex, holdPanelTex, scoreX, statsW, gridY, finalBlockSize, statsY, statsH);
|
||||||
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 grid lines (solid so grid remains legible over background)
|
// Draw grid lines (solid so grid remains legible over background)
|
||||||
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
|
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 - 3 - contentOffsetX, statsY - 3 - contentOffsetY, statsW + 6, statsH + 6, {100, 120, 200, 255});
|
||||||
drawRectWithOffset(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255});
|
drawRectWithOffset(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255});
|
||||||
|
|
||||||
// Draw next piece preview panel border
|
// Draw next piece panel
|
||||||
// If a NEXT panel texture was provided, draw it instead of the custom
|
drawNextPanel(renderer, pixelFont, nextPanelTex, blocksTex, game, nextX, nextY, nextW, nextH, contentOffsetX, contentOffsetY, finalBlockSize);
|
||||||
// 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 the game board
|
// Draw the game board
|
||||||
const auto &board = game->boardRef();
|
const auto &board = game->boardRef();
|
||||||
for (int y = 0; y < Game::ROWS; ++y) {
|
for (int y = 0; y < Game::ROWS; ++y) {
|
||||||
@ -411,53 +510,8 @@ void GameRenderer::renderPlayingState(
|
|||||||
yCursor = rowBottom + rowSpacing;
|
yCursor = rowBottom + rowSpacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw score panel (right side)
|
// Draw score panel
|
||||||
const float contentTopOffset = 0.0f;
|
drawScorePanel(renderer, pixelFont, game, scoreX, gridY, GRID_H, finalBlockSize);
|
||||||
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});
|
|
||||||
|
|
||||||
// Gravity HUD
|
// Gravity HUD
|
||||||
char gms[64];
|
char gms[64];
|
||||||
@ -466,10 +520,76 @@ void GameRenderer::renderPlayingState(
|
|||||||
snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps);
|
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});
|
pixelFont->draw(renderer, logicalW - 260, 10, gms, 0.9f, {200, 200, 220, 255});
|
||||||
|
|
||||||
// Hold piece (if implemented)
|
// Hold panel (always visible): draw background & label; preview shown only when a piece is held.
|
||||||
if (game->held().type < PIECE_COUNT) {
|
{
|
||||||
pixelFont->draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255});
|
float holdBlockH = (finalBlockSize * 0.6f) * 4.0f;
|
||||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f);
|
// 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)
|
// Pause overlay (suppressed when requested, e.g., countdown)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public:
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
|
|||||||
287
src/graphics/challenge_mode.md
Normal 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 2–4 rows.
|
||||||
|
- Mid levels: bottom 6–10 rows.
|
||||||
|
- Late levels: up to ~half board height.
|
||||||
|
- Use a function to define a `minRow..maxRow` region based on `level`.
|
||||||
|
|
||||||
|
Example guidance:
|
||||||
|
- `maxRow = boardHeight - 1`
|
||||||
|
- `minRow = boardHeight - 1 - clamp(2 + level/3, 2, boardHeight/2)`
|
||||||
|
|
||||||
|
### Type Distribution by Level (suggested)
|
||||||
|
- Levels 1–9: Normal only
|
||||||
|
- Levels 10–19: add Armored (small %)
|
||||||
|
- Levels 20–59: add Falling (increasing %)
|
||||||
|
- Levels 60–100: add Core (increasing %)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Difficulty Scaling
|
||||||
|
|
||||||
|
### Gravity Speed Scaling
|
||||||
|
Implement per-level gravity scale:
|
||||||
|
- `gravity = baseGravity * (1.0f + level * 0.02f)` (tune)
|
||||||
|
- Or use a curve/table.
|
||||||
|
|
||||||
|
Optional additional scaling:
|
||||||
|
- Reduced lock delay slightly at higher levels
|
||||||
|
- Slightly faster DAS/ARR (if implemented)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Win/Lose Conditions
|
||||||
|
|
||||||
|
### Level Completion
|
||||||
|
- Level completes when: `asteroidsRemaining == 0`
|
||||||
|
- Then:
|
||||||
|
- Clear board (or keep board — choose one consistent behavior; recommended: **clear board** for clean progression).
|
||||||
|
- Show short transition (optional).
|
||||||
|
- Load next level, until level 100.
|
||||||
|
- After level 100 completion: show completion screen + stats.
|
||||||
|
|
||||||
|
### Game Over
|
||||||
|
- Standard Tetris game over: stack reaches spawn/top (existing behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Rendering / UI Requirements
|
||||||
|
|
||||||
|
### Visual Differentiation
|
||||||
|
Asteroids must be visually distinct from normal tetromino blocks.
|
||||||
|
|
||||||
|
Provide visual states:
|
||||||
|
- Normal: rock texture
|
||||||
|
- Armored: plated / darker
|
||||||
|
- Cracked: visible cracks
|
||||||
|
- Falling: glow rim / hazard stripes
|
||||||
|
- Core: pulsing inner core
|
||||||
|
|
||||||
|
Minimum UI additions (Challenge):
|
||||||
|
- Display `LEVEL: X/100`
|
||||||
|
- Display `ASTEROIDS REMAINING: N` (or an icon counter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Data Structures (C++ Guidance)
|
||||||
|
|
||||||
|
### Cell Representation
|
||||||
|
Each grid cell must store:
|
||||||
|
- Whether occupied
|
||||||
|
- If occupied: is it part of normal tetromino or an asteroid
|
||||||
|
- If asteroid: type + hitsRemaining + gravityEnabled + visualState
|
||||||
|
|
||||||
|
Suggested enums:
|
||||||
|
```cpp
|
||||||
|
enum class CellKind { Empty, Tetromino, Asteroid };
|
||||||
|
|
||||||
|
enum class AsteroidType { Normal, Armored, Falling, Core };
|
||||||
|
|
||||||
|
struct AsteroidCell {
|
||||||
|
AsteroidType type;
|
||||||
|
uint8_t hitsRemaining;
|
||||||
|
bool gravityEnabled;
|
||||||
|
uint8_t visualState; // optional (e.g. 0..n)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Cell {
|
||||||
|
CellKind kind;
|
||||||
|
// For Tetromino: color/type id
|
||||||
|
// For Asteroid: AsteroidCell data
|
||||||
|
};
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Line Clear Processing Rules (Important)
|
||||||
|
|
||||||
|
When a line is cleared:
|
||||||
|
|
||||||
|
1. Detect full rows (existing).
|
||||||
|
2. For each cleared row:
|
||||||
|
|
||||||
|
* For each cell:
|
||||||
|
|
||||||
|
* If `kind == Asteroid`:
|
||||||
|
|
||||||
|
* `hitsRemaining--`
|
||||||
|
* If `hitsRemaining == 0`: remove (cell becomes Empty)
|
||||||
|
* Else:
|
||||||
|
|
||||||
|
* Update its visual state (cracked/damaged)
|
||||||
|
* If asteroid type is Falling/Core and rule says it becomes gravity-enabled on first hit:
|
||||||
|
|
||||||
|
* `gravityEnabled = true`
|
||||||
|
3. After clearing rows and collapsing the grid:
|
||||||
|
|
||||||
|
* Apply **asteroid gravity step**:
|
||||||
|
|
||||||
|
* For all gravity-enabled asteroid cells: let them fall until resting.
|
||||||
|
* Ensure stable iteration (bottom-up scan).
|
||||||
|
4. Recount asteroids remaining; if 0 -> level complete.
|
||||||
|
|
||||||
|
**Note:** Decide whether gravity-enabled asteroids fall immediately after the first hit (recommended) and whether they fall as individual cells (recommended) or as clusters (optional later).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Asteroid Gravity Algorithm (Simple + Stable)
|
||||||
|
|
||||||
|
Implement a pass:
|
||||||
|
|
||||||
|
* Iterate from bottom-2 to top (bottom-up).
|
||||||
|
* If cell is gravity-enabled asteroid and below is empty:
|
||||||
|
|
||||||
|
* Move down by one
|
||||||
|
* Repeat passes until no movement OR do a while-loop per cell to drop fully.
|
||||||
|
|
||||||
|
Be careful to avoid skipping cells when moving:
|
||||||
|
|
||||||
|
* Use bottom-up iteration and drop-to-bottom logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Level Generation (Deterministic Option)
|
||||||
|
|
||||||
|
To make challenge reproducible:
|
||||||
|
|
||||||
|
* Use a seed: `seed = baseSeed + level`
|
||||||
|
* Place asteroids with RNG based on level seed.
|
||||||
|
|
||||||
|
Placement constraints:
|
||||||
|
|
||||||
|
* Avoid placing asteroids in the spawn zone/top rows.
|
||||||
|
* Avoid creating impossible scenarios too early:
|
||||||
|
|
||||||
|
* For early levels, ensure at least one vertical shaft exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Tasks Checklist for AI Agent
|
||||||
|
|
||||||
|
### A) Add Challenge Level System
|
||||||
|
|
||||||
|
* [ ] Add `currentLevel (1..100)` and `mode == CHALLENGE`.
|
||||||
|
* [ ] Add `StartChallengeLevel(level)` function.
|
||||||
|
* [ ] Reset/prepare board state for each level (recommended: clear board).
|
||||||
|
|
||||||
|
### B) Asteroid Placement
|
||||||
|
|
||||||
|
* [ ] Implement `PlaceAsteroids(level)`:
|
||||||
|
|
||||||
|
* Determine region of rows
|
||||||
|
* Choose type distribution
|
||||||
|
* Place `level` asteroid cells into empty spots
|
||||||
|
|
||||||
|
### C) Line Clear Hook
|
||||||
|
|
||||||
|
* [ ] Modify existing line clear code:
|
||||||
|
|
||||||
|
* Apply asteroid hit logic
|
||||||
|
* Update visuals
|
||||||
|
* Enable gravity where required
|
||||||
|
|
||||||
|
### D) Gravity-enabled Asteroids
|
||||||
|
|
||||||
|
* [ ] Implement `ApplyAsteroidGravity()` after line clears and board collapse.
|
||||||
|
|
||||||
|
### E) Level Completion
|
||||||
|
|
||||||
|
* [ ] Track `asteroidsRemaining`.
|
||||||
|
* [ ] When 0: trigger level transition and `StartChallengeLevel(level+1)`.
|
||||||
|
|
||||||
|
### F) UI
|
||||||
|
|
||||||
|
* [ ] Add level & asteroids remaining display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Acceptance Criteria
|
||||||
|
|
||||||
|
* Level 1 spawns exactly 1 asteroid.
|
||||||
|
* Level N spawns N asteroids.
|
||||||
|
* Destroying asteroids requires:
|
||||||
|
|
||||||
|
* Normal: 1 clear
|
||||||
|
* Armored: 2 clears
|
||||||
|
* Falling: 2 clears + becomes gravity-enabled after first hit
|
||||||
|
* Core: 3 clears (+ gravity-enabled rule)
|
||||||
|
* Player advances only when all asteroids are destroyed.
|
||||||
|
* Gravity increases by level and is clearly noticeable by mid-levels.
|
||||||
|
* No infinite loops in placement or gravity.
|
||||||
|
* Challenge works end-to-end through level 100.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Notes / Tuning Hooks
|
||||||
|
|
||||||
|
Expose tuning constants:
|
||||||
|
|
||||||
|
* `baseGravity`
|
||||||
|
* `gravityPerLevel`
|
||||||
|
* `minAsteroidRow(level)`
|
||||||
|
* `typeDistribution(level)` weights
|
||||||
|
* `coreGravityOnHit` rule
|
||||||
|
|
||||||
|
---
|
||||||
@ -107,8 +107,22 @@ void SpaceWarp::spawnComet() {
|
|||||||
float normalizedAspect = std::max(aspect, MIN_ASPECT);
|
float normalizedAspect = std::max(aspect, MIN_ASPECT);
|
||||||
float xRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? aspect : 1.0f);
|
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));
|
float yRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect));
|
||||||
comet.x = randomRange(-xRange, xRange);
|
// Avoid spawning comets exactly on (or extremely near) the view axis,
|
||||||
comet.y = randomRange(-yRange, yRange);
|
// 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);
|
comet.z = randomRange(minDepth + 4.0f, maxDepth);
|
||||||
float baseSpeed = randomRange(settings.minSpeed, settings.maxSpeed);
|
float baseSpeed = randomRange(settings.minSpeed, settings.maxSpeed);
|
||||||
float multiplier = randomRange(settings.cometSpeedMultiplierMin, settings.cometSpeedMultiplierMax);
|
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 normalizedAspect = std::max(aspect, MIN_ASPECT);
|
||||||
float xRange = settings.baseSpawnRange * (aspect >= 1.0f ? aspect : 1.0f);
|
float xRange = settings.baseSpawnRange * (aspect >= 1.0f ? aspect : 1.0f);
|
||||||
float yRange = settings.baseSpawnRange * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect));
|
float yRange = settings.baseSpawnRange * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect));
|
||||||
star.x = randomRange(-xRange, xRange);
|
// Avoid axis-aligned stars (x≈0,y≈0) which can project to a static, bright center dot.
|
||||||
star.y = randomRange(-yRange, yRange);
|
const float axisMinFrac = 0.06f;
|
||||||
star.z = randomDepth ? randomRange(minDepth, maxDepth) : maxDepth;
|
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.speed = randomRange(settings.minSpeed, settings.maxSpeed);
|
||||||
star.shade = randomRange(settings.minShade, settings.maxShade);
|
star.shade = randomRange(settings.minShade, settings.maxShade);
|
||||||
static constexpr Uint8 GRAY_SHADES[] = {160, 180, 200, 220, 240};
|
static constexpr Uint8 GRAY_SHADES[] = {160, 180, 200, 220, 240};
|
||||||
@ -253,6 +282,13 @@ void SpaceWarp::update(float deltaSeconds) {
|
|||||||
continue;
|
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.prevScreenX = star.screenX;
|
||||||
star.prevScreenY = star.screenY;
|
star.prevScreenY = star.screenY;
|
||||||
star.screenX = sx;
|
star.screenX = sx;
|
||||||
|
|||||||
@ -68,9 +68,24 @@ void Starfield3D::setRandomDirection(Star3D& star) {
|
|||||||
|
|
||||||
void Starfield3D::updateStar(int index) {
|
void Starfield3D::updateStar(int index) {
|
||||||
Star3D& star = stars[index];
|
Star3D& star = stars[index];
|
||||||
|
|
||||||
star.x = randomFloat(-25.0f, 25.0f);
|
// Avoid spawning stars on (or very near) the view axis. A star with x≈0 and y≈0
|
||||||
star.y = randomFloat(-25.0f, 25.0f);
|
// 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);
|
star.z = randomFloat(1.0f, MAX_DEPTH);
|
||||||
|
|
||||||
// Give stars initial velocities in all possible directions
|
// 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);
|
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.targetVx = star.vx;
|
||||||
star.targetVy = star.vy;
|
star.targetVy = star.vy;
|
||||||
|
|||||||
@ -518,6 +518,7 @@ void GameRenderer::renderPlayingState(
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
@ -1357,6 +1358,11 @@ void GameRenderer::renderPlayingState(
|
|||||||
statLines.push_back({dropStr, 370.0f, 0.7f, dropColor});
|
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()) {
|
if (!statLines.empty()) {
|
||||||
float statsContentTop = std::numeric_limits<float>::max();
|
float statsContentTop = std::numeric_limits<float>::max();
|
||||||
float statsContentBottom = std::numeric_limits<float>::lowest();
|
float statsContentBottom = std::numeric_limits<float>::lowest();
|
||||||
@ -1383,6 +1389,11 @@ void GameRenderer::renderPlayingState(
|
|||||||
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205);
|
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205);
|
||||||
SDL_RenderFillRect(renderer, &statsBg);
|
SDL_RenderFillRect(renderer, &statsBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scorePanelMetricsValid = true;
|
||||||
|
scorePanelTop = statsPanelTop;
|
||||||
|
scorePanelLeftX = statsPanelLeft;
|
||||||
|
scorePanelWidth = statsPanelWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& line : statLines) {
|
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});
|
pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hold piece (if implemented)
|
// Hold panel background & label (always visible). Small preview renders only if a piece is held.
|
||||||
if (game->held().type < PIECE_COUNT) {
|
{
|
||||||
pixelFont->draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255});
|
float holdLabelX = statsTextX;
|
||||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f);
|
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
|
// Pause overlay logic moved to renderPauseOverlay
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public:
|
|||||||
SDL_Texture* statisticsPanelTex,
|
SDL_Texture* statisticsPanelTex,
|
||||||
SDL_Texture* scorePanelTex,
|
SDL_Texture* scorePanelTex,
|
||||||
SDL_Texture* nextPanelTex,
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
float logicalW,
|
float logicalW,
|
||||||
float logicalH,
|
float logicalH,
|
||||||
float logicalScale,
|
float logicalScale,
|
||||||
|
|||||||
16
src/graphics/renderers/RenderPrimitives.h
Normal 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
|
||||||
@ -39,10 +39,34 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f
|
|||||||
float x = cx - w * 0.5f;
|
float x = cx - w * 0.5f;
|
||||||
float y = cy - h * 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) {
|
if (!textOnly) {
|
||||||
// Adjust colors based on state
|
// Adjust colors based on state
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
bgColor = {160, 190, 255, 255};
|
// Keep caller-provided colors; just add a stronger glow.
|
||||||
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 110);
|
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 110);
|
||||||
SDL_FRect glow{x - 10, y - 10, w + 20, h + 20};
|
SDL_FRect glow{x - 10, y - 10, w + 20, h + 20};
|
||||||
SDL_RenderFillRect(renderer, &glow);
|
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)
|
// Neon glow aura around the button to increase visibility (subtle)
|
||||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
||||||
for (int gi = 0; gi < 3; ++gi) {
|
for (int gi = 0; gi < 3; ++gi) {
|
||||||
float grow = 6.0f + gi * 3.0f;
|
float grow = 6.0f + gi * 3.0f;
|
||||||
Uint8 glowA = static_cast<Uint8>(std::max(0, (int)borderColor.a / (3 - gi)));
|
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 iconX = cx - scaledW * 0.5f;
|
||||||
float iconY = cy - scaledH * 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) {
|
if (isSelected) {
|
||||||
SDL_SetTextureColorMod(icon, 255, 220, 0);
|
SDL_SetTextureColorMod(icon, 255, 220, 0);
|
||||||
} else {
|
} else {
|
||||||
SDL_SetTextureColorMod(icon, 255, 255, 255);
|
SDL_SetTextureColorMod(icon, 255, 255, 255);
|
||||||
}
|
}
|
||||||
|
SDL_SetTextureAlphaMod(icon, 255);
|
||||||
SDL_FRect iconRect{iconX, iconY, scaledW, scaledH};
|
|
||||||
SDL_RenderTexture(renderer, icon, nullptr, &iconRect);
|
SDL_RenderTexture(renderer, icon, nullptr, &iconRect);
|
||||||
|
|
||||||
// Reset color mod
|
// Reset
|
||||||
SDL_SetTextureColorMod(icon, 255, 255, 255);
|
SDL_SetTextureColorMod(icon, 255, 255, 255);
|
||||||
|
SDL_SetTextureAlphaMod(icon, 255);
|
||||||
} else if (font) {
|
} else if (font) {
|
||||||
// Draw text (smaller scale for tighter buttons)
|
// Draw text with scale based on button height.
|
||||||
float textScale = 1.2f;
|
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;
|
int textW = 0, textH = 0;
|
||||||
font->measure(label, textScale, textW, textH);
|
font->measure(label, textScale, textW, textH);
|
||||||
float tx = x + (w - static_cast<float>(textW)) * 0.5f;
|
float tx = x + (w - static_cast<float>(textW)) * 0.5f;
|
||||||
// Adjust vertical position for better alignment with background buttons
|
// Vertically center text within the button.
|
||||||
// Vertically center text precisely within the button
|
float ty = y + (h - static_cast<float>(textH)) * 0.5f;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Choose text color based on selection state
|
// Choose text color based on selection state
|
||||||
SDL_Color textColor = {255, 255, 255, 255}; // Default white
|
SDL_Color textColor = {255, 255, 255, 255}; // Default white
|
||||||
|
|||||||
@ -34,7 +34,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
|||||||
if (!renderer) return;
|
if (!renderer) return;
|
||||||
|
|
||||||
const std::array<ShortcutEntry, 5> generalShortcuts{{
|
const std::array<ShortcutEntry, 5> generalShortcuts{{
|
||||||
{"H", "Toggle this help overlay"},
|
{"F1", "Toggle this help overlay"},
|
||||||
{"ESC", "Back / cancel current popup"},
|
{"ESC", "Back / cancel current popup"},
|
||||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||||
{"M", "Mute or unmute music"},
|
{"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"}
|
{"ENTER / SPACE", "Activate highlighted action"}
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const std::array<ShortcutEntry, 7> gameplayShortcuts{{
|
const std::array<ShortcutEntry, 8> gameplayShortcuts{{
|
||||||
{"LEFT / RIGHT", "Move active piece"},
|
{"LEFT / RIGHT", "Move active piece"},
|
||||||
{"DOWN", "Soft drop (faster fall)"},
|
{"DOWN", "Soft drop (faster fall)"},
|
||||||
{"SPACE", "Hard drop / instant lock"},
|
{"SPACE", "Hard drop / instant lock"},
|
||||||
{"UP", "Rotate clockwise"},
|
{"UP", "Rotate clockwise"},
|
||||||
|
{"H", "Hold / swap current piece"},
|
||||||
{"X", "Toggle rotation direction used by UP"},
|
{"X", "Toggle rotation direction used by UP"},
|
||||||
{"P", "Pause or resume"},
|
{"P", "Pause or resume"},
|
||||||
{"ESC", "Open exit confirmation"}
|
{"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_SetRenderDrawColor(renderer, 90, 110, 170, 255);
|
||||||
SDL_RenderRect(renderer, &footerRect);
|
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);
|
float closeScale = fitScale(font, closeLabel, 1.0f, footerRect.w - footerPadding * 2.0f);
|
||||||
int closeW = 0, closeH = 0;
|
int closeW = 0, closeH = 0;
|
||||||
font.measure(closeLabel, closeScale, closeW, closeH);
|
font.measure(closeLabel, closeScale, closeW, closeH);
|
||||||
|
|||||||
2067
src/main.cpp
17
src/resources/AssetPaths.h
Normal 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/";
|
||||||
|
}
|
||||||
39
src/states/LoadingManager.cpp
Normal 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();
|
||||||
|
}
|
||||||
36
src/states/LoadingManager.h
Normal 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;
|
||||||
|
};
|
||||||
@ -25,6 +25,8 @@
|
|||||||
#include "../utils/ImagePathResolver.h"
|
#include "../utils/ImagePathResolver.h"
|
||||||
#include "../graphics/renderers/UIRenderer.h"
|
#include "../graphics/renderers/UIRenderer.h"
|
||||||
#include "../graphics/renderers/GameRenderer.h"
|
#include "../graphics/renderers/GameRenderer.h"
|
||||||
|
#include "../ui/MenuLayout.h"
|
||||||
|
#include "../ui/BottomMenu.h"
|
||||||
#include <SDL3_image/SDL_image.h>
|
#include <SDL3_image/SDL_image.h>
|
||||||
|
|
||||||
// Frosted tint helper: draw a safe, inexpensive frosted overlay for the panel area.
|
// 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) {
|
void MenuState::showHelpPanel(bool show) {
|
||||||
if (show) {
|
if (show) {
|
||||||
if (!helpPanelVisible && !helpPanelAnimating) {
|
if (!helpPanelVisible && !helpPanelAnimating) {
|
||||||
|
// Avoid overlapping panels
|
||||||
|
if (aboutPanelVisible && !aboutPanelAnimating) {
|
||||||
|
aboutPanelAnimating = true;
|
||||||
|
aboutDirection = -1;
|
||||||
|
}
|
||||||
helpPanelAnimating = true;
|
helpPanelAnimating = true;
|
||||||
helpDirection = 1;
|
helpDirection = 1;
|
||||||
helpScroll = 0.0;
|
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() {
|
void MenuState::onEnter() {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState::onEnter called");
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState::onEnter called");
|
||||||
if (ctx.showExitConfirmPopup) {
|
if (ctx.showExitConfirmPopup) {
|
||||||
@ -135,103 +174,25 @@ void MenuState::onEnter() {
|
|||||||
void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||||
const float LOGICAL_W = 1200.f;
|
const float LOGICAL_W = 1200.f;
|
||||||
const float LOGICAL_H = 1000.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;
|
// Use the same layout code as mouse hit-testing so each button is the same size.
|
||||||
bool isSmall = (contentW < 700.0f);
|
ui::MenuLayoutParams params{
|
||||||
float btnW = 200.0f;
|
static_cast<int>(LOGICAL_W),
|
||||||
float btnH = 70.0f;
|
static_cast<int>(LOGICAL_H),
|
||||||
float btnX = LOGICAL_W * 0.5f + contentOffsetX;
|
logicalVP.w,
|
||||||
// move buttons a bit lower for better visibility
|
logicalVP.h,
|
||||||
// small global vertical offset for the whole menu (tweak to move UI down)
|
logicalScale
|
||||||
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" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
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);
|
||||||
// Draw semi-transparent background panel behind the full button group (draw first so text sits on top)
|
const double flashDelta = buttonFlash * buttonFlashAmount;
|
||||||
// `groupCenterY` is declared here so it can be used when drawing the buttons below.
|
ui::renderBottomMenu(renderer, ctx.pixelFont, menu, hovered, selectedButton, baseAlpha, pulseDelta + flashDelta);
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuState::onExit() {
|
void MenuState::onExit() {
|
||||||
@ -250,6 +211,11 @@ void MenuState::onExit() {
|
|||||||
void MenuState::handleEvent(const SDL_Event& e) {
|
void MenuState::handleEvent(const SDL_Event& e) {
|
||||||
// Keyboard navigation for menu buttons
|
// Keyboard navigation for menu buttons
|
||||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
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 = [&]() {
|
auto triggerPlay = [&]() {
|
||||||
if (ctx.startPlayTransition) {
|
if (ctx.startPlayTransition) {
|
||||||
ctx.startPlayTransition();
|
ctx.startPlayTransition();
|
||||||
@ -401,14 +367,40 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
// Close help panel
|
// Close help panel
|
||||||
helpPanelAnimating = true; helpDirection = -1;
|
helpPanelAnimating = true; helpDirection = -1;
|
||||||
return;
|
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_PAGEDOWN:
|
||||||
case SDL_SCANCODE_DOWN: {
|
helpScroll += 40.0;
|
||||||
helpScroll += 40.0; return;
|
return;
|
||||||
}
|
|
||||||
case SDL_SCANCODE_PAGEUP:
|
case SDL_SCANCODE_PAGEUP:
|
||||||
case SDL_SCANCODE_UP: {
|
helpScroll -= 40.0;
|
||||||
helpScroll -= 40.0; if (helpScroll < 0.0) helpScroll = 0.0; return;
|
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:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -450,7 +442,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
case SDL_SCANCODE_LEFT:
|
case SDL_SCANCODE_LEFT:
|
||||||
case SDL_SCANCODE_UP:
|
case SDL_SCANCODE_UP:
|
||||||
{
|
{
|
||||||
const int total = 5;
|
const int total = 6;
|
||||||
selectedButton = (selectedButton + total - 1) % total;
|
selectedButton = (selectedButton + total - 1) % total;
|
||||||
// brief bright flash on navigation
|
// brief bright flash on navigation
|
||||||
buttonFlash = 1.0;
|
buttonFlash = 1.0;
|
||||||
@ -459,7 +451,7 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
case SDL_SCANCODE_RIGHT:
|
case SDL_SCANCODE_RIGHT:
|
||||||
case SDL_SCANCODE_DOWN:
|
case SDL_SCANCODE_DOWN:
|
||||||
{
|
{
|
||||||
const int total = 5;
|
const int total = 6;
|
||||||
selectedButton = (selectedButton + 1) % total;
|
selectedButton = (selectedButton + 1) % total;
|
||||||
// brief bright flash on navigation
|
// brief bright flash on navigation
|
||||||
buttonFlash = 1.0;
|
buttonFlash = 1.0;
|
||||||
@ -509,6 +501,16 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 4:
|
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
|
// Show the inline exit HUD
|
||||||
if (!exitPanelVisible && !exitPanelAnimating) {
|
if (!exitPanelVisible && !exitPanelAnimating) {
|
||||||
exitPanelAnimating = true;
|
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
|
// Animate level selection highlight position toward the selected cell center
|
||||||
if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) {
|
if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) {
|
||||||
// Recompute same grid geometry used in render to find target center
|
// 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) {
|
if (buttonPulseEnabled) {
|
||||||
buttonPulseTime += frameMs;
|
buttonPulseTime += frameMs;
|
||||||
double t = (buttonPulseTime * 0.001) * buttonPulseSpeed; // seconds * speed
|
double t = (buttonPulseTime * 0.001) * buttonPulseSpeed; // seconds * speed
|
||||||
@ -676,11 +693,14 @@ void MenuState::update(double frameMs) {
|
|||||||
default:
|
default:
|
||||||
s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5;
|
s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5;
|
||||||
}
|
}
|
||||||
buttonGroupAlpha = buttonPulseMinAlpha + s * (buttonPulseMaxAlpha - buttonPulseMinAlpha);
|
buttonPulseAlpha = buttonPulseMinAlpha + s * (buttonPulseMaxAlpha - buttonPulseMinAlpha);
|
||||||
} else {
|
} 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
|
// Update flash decay
|
||||||
if (buttonFlash > 0.0) {
|
if (buttonFlash > 0.0) {
|
||||||
buttonFlash -= frameMs * buttonFlashDecay;
|
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
|
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.
|
// 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 eased = combinedTransition * combinedTransition * (3.0f - 2.0f * combinedTransition); // cubic smoothstep
|
||||||
float panelDelta = eased * moveAmount;
|
float panelDelta = eased * moveAmount;
|
||||||
|
|
||||||
// Draw a larger centered logo above the highscores area, then a small "TOP PLAYER" label
|
// 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 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;
|
float scoresStartY = topPlayersY;
|
||||||
if (useFont) {
|
if (useFont) {
|
||||||
// Preferred logo texture (full) if present, otherwise the small logo
|
// 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)
|
// Shortcut entries (copied from HelpOverlay)
|
||||||
struct ShortcutEntry { const char* combo; const char* description; };
|
struct ShortcutEntry { const char* combo; const char* description; };
|
||||||
const ShortcutEntry generalShortcuts[] = {
|
const ShortcutEntry generalShortcuts[] = {
|
||||||
{"H", "Toggle this help overlay"},
|
{"F1", "Toggle this help overlay"},
|
||||||
{"ESC", "Back / cancel current popup"},
|
{"ESC", "Back / cancel current popup"},
|
||||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||||
{"M", "Mute or unmute music"},
|
{"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)"},
|
{"DOWN", "Soft drop (faster fall)"},
|
||||||
{"SPACE", "Hard drop / instant lock"},
|
{"SPACE", "Hard drop / instant lock"},
|
||||||
{"UP", "Rotate clockwise"},
|
{"UP", "Rotate clockwise"},
|
||||||
|
{"H", "Hold / swap current piece"},
|
||||||
{"X", "Toggle rotation direction used by UP"},
|
{"X", "Toggle rotation direction used by UP"},
|
||||||
{"P", "Pause or resume"},
|
{"P", "Pause or resume"},
|
||||||
{"ESC", "Open exit confirmation"}
|
{"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);
|
int w=0,h=0; f->measure(entry.description, 0.62f, w, h);
|
||||||
cursorY += static_cast<float>(h) + 16.0f;
|
cursorY += static_cast<float>(h) + 16.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (rest of help render continues below)
|
||||||
// Add a larger gap between sections
|
// Add a larger gap between sections
|
||||||
cursorY += 22.0f;
|
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);
|
const float contentTopY = panelY + 30.0f;
|
||||||
float rightCursor = panelY + 48.0f - static_cast<float>(helpScroll);
|
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, "GENERAL", generalShortcuts, (int)(sizeof(generalShortcuts)/sizeof(generalShortcuts[0])));
|
||||||
drawSection(leftX, leftCursor, "MENUS", menuShortcuts, (int)(sizeof(menuShortcuts)/sizeof(menuShortcuts[0])));
|
drawSection(leftX, leftCursor, "MENUS", menuShortcuts, (int)(sizeof(menuShortcuts)/sizeof(menuShortcuts[0])));
|
||||||
drawSection(rightX, rightCursor, "GAMEPLAY", gameplayShortcuts, (int)(sizeof(gameplayShortcuts)/sizeof(gameplayShortcuts[0])));
|
drawSection(rightX, rightCursor, "GAMEPLAY", gameplayShortcuts, (int)(sizeof(gameplayShortcuts)/sizeof(gameplayShortcuts[0])));
|
||||||
|
|
||||||
// Ensure helpScroll bounds (simple clamp)
|
// 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));
|
float maxScroll = std::max(0.0f, contentHeight - (PH - 120.0f));
|
||||||
if (helpScroll < 0.0) helpScroll = 0.0;
|
if (helpScroll < 0.0) helpScroll = 0.0;
|
||||||
if (helpScroll > maxScroll) helpScroll = maxScroll;
|
if (helpScroll > maxScroll) helpScroll = maxScroll;
|
||||||
|
|||||||
@ -17,9 +17,11 @@ public:
|
|||||||
void renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP);
|
void renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP);
|
||||||
// Show or hide the inline HELP panel (menu-style)
|
// Show or hide the inline HELP panel (menu-style)
|
||||||
void showHelpPanel(bool show);
|
void showHelpPanel(bool show);
|
||||||
|
// Show or hide the inline ABOUT panel (menu-style)
|
||||||
|
void showAboutPanel(bool show);
|
||||||
|
|
||||||
private:
|
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)
|
// Button icons (optional - will use text if nullptr)
|
||||||
SDL_Texture* playIcon = nullptr;
|
SDL_Texture* playIcon = nullptr;
|
||||||
@ -56,8 +58,9 @@ private:
|
|||||||
double levelHighlightGlowAlpha = 0.70; // 0..1 base glow alpha
|
double levelHighlightGlowAlpha = 0.70; // 0..1 base glow alpha
|
||||||
int levelHighlightThickness = 3; // inner outline thickness (px)
|
int levelHighlightThickness = 3; // inner outline thickness (px)
|
||||||
SDL_Color levelHighlightColor = SDL_Color{80, 200, 255, 200};
|
SDL_Color levelHighlightColor = SDL_Color{80, 200, 255, 200};
|
||||||
// Button group pulsing/fade parameters (applies to all four main buttons)
|
// Button pulsing/fade parameters (used for PLAY emphasis)
|
||||||
double buttonGroupAlpha = 1.0; // current computed alpha (0..1)
|
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
|
double buttonPulseTime = 0.0; // accumulator in ms
|
||||||
bool buttonPulseEnabled = true; // enable/disable pulsing
|
bool buttonPulseEnabled = true; // enable/disable pulsing
|
||||||
double buttonPulseSpeed = 1.0; // multiplier for pulse frequency
|
double buttonPulseSpeed = 1.0; // multiplier for pulse frequency
|
||||||
@ -84,4 +87,11 @@ private:
|
|||||||
double helpTransitionDurationMs = 360.0;
|
double helpTransitionDurationMs = 360.0;
|
||||||
int helpDirection = 1; // 1 show, -1 hide
|
int helpDirection = 1; // 1 show, -1 hide
|
||||||
double helpScroll = 0.0; // vertical scroll offset for content
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -118,6 +118,12 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
|
|
||||||
// Tetris controls (only when not paused)
|
// Tetris controls (only when not paused)
|
||||||
if (!ctx.game->isPaused()) {
|
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)
|
// Rotation (still event-based for precise timing)
|
||||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
// Use user setting to determine whether UP rotates clockwise
|
// 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.statisticsPanelTex,
|
||||||
ctx.scorePanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.nextPanelTex,
|
||||||
|
ctx.holdPanelTex,
|
||||||
1200.0f, // LOGICAL_W
|
1200.0f, // LOGICAL_W
|
||||||
1000.0f, // LOGICAL_H
|
1000.0f, // LOGICAL_H
|
||||||
logicalScale,
|
logicalScale,
|
||||||
@ -319,6 +326,7 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
ctx.statisticsPanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.scorePanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.nextPanelTex,
|
||||||
|
ctx.holdPanelTex,
|
||||||
1200.0f,
|
1200.0f,
|
||||||
1000.0f,
|
1000.0f,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
|
|||||||
@ -43,6 +43,7 @@ struct StateContext {
|
|||||||
SDL_Texture* scorePanelTex = nullptr;
|
SDL_Texture* scorePanelTex = nullptr;
|
||||||
SDL_Texture* statisticsPanelTex = nullptr;
|
SDL_Texture* statisticsPanelTex = nullptr;
|
||||||
SDL_Texture* nextPanelTex = nullptr;
|
SDL_Texture* nextPanelTex = nullptr;
|
||||||
|
SDL_Texture* holdPanelTex = nullptr; // Background for the HOLD preview
|
||||||
SDL_Texture* mainScreenTex = nullptr;
|
SDL_Texture* mainScreenTex = nullptr;
|
||||||
int mainScreenW = 0;
|
int mainScreenW = 0;
|
||||||
int mainScreenH = 0;
|
int mainScreenH = 0;
|
||||||
|
|||||||
129
src/ui/BottomMenu.cpp
Normal 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
@ -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
@ -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
@ -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
|
||||||
@ -2,6 +2,7 @@
|
|||||||
#include "MenuWrappers.h"
|
#include "MenuWrappers.h"
|
||||||
#include "../core/GlobalState.h"
|
#include "../core/GlobalState.h"
|
||||||
#include "../graphics/Font.h"
|
#include "../graphics/Font.h"
|
||||||
|
#include "app/Fireworks.h"
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
using namespace Globals;
|
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) {
|
void menu_drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) {
|
||||||
GlobalState::instance().drawFireworks(renderer, blocksTex);
|
AppFireworks::draw(renderer, blocksTex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void menu_updateFireworks(double frameMs) {
|
void menu_updateFireworks(double frameMs) {
|
||||||
GlobalState::instance().updateFireworks(frameMs);
|
AppFireworks::update(frameMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
double menu_getLogoAnimCounter() {
|
double menu_getLogoAnimCounter() {
|
||||||
return GlobalState::instance().logoAnimCounter;
|
return AppFireworks::getLogoAnimCounter();
|
||||||
}
|
}
|
||||||
|
|
||||||
int menu_getHoveredButton() {
|
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,
|
void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h,
|
||||||
|
|||||||
18
src/ui/UIConstants.h
Normal 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;
|
||||||