diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c8044c..1e22b56 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,9 +44,11 @@ set(TETRIS_SOURCES src/persistence/Scores.cpp src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield3D.cpp + src/graphics/effects/SpaceWarp.cpp src/graphics/ui/Font.cpp src/graphics/ui/HelpOverlay.cpp src/graphics/renderers/GameRenderer.cpp + src/graphics/renderers/UIRenderer.cpp src/audio/Audio.cpp src/gameplay/effects/LineEffect.cpp src/audio/SoundEffect.cpp diff --git a/CODE_ANALYSIS.md b/CODE_ANALYSIS.md new file mode 100644 index 0000000..d2b932b --- /dev/null +++ b/CODE_ANALYSIS.md @@ -0,0 +1,760 @@ +# Tetris SDL3 - Code Analysis & Best Practices Review + +**Generated:** 2025-12-03 +**Project:** Tetris Game (SDL3) + +--- + +## ๐Ÿ“Š Executive Summary + +Your Tetris project is **well-structured and follows many modern C++ best practices**. The codebase demonstrates: +- โœ… Clean separation of concerns with a state-based architecture +- โœ… Modern C++20 features and RAII patterns +- โœ… Centralized configuration management +- โœ… Proper dependency management via vcpkg +- โœ… Good documentation and code organization + +However, there are opportunities for improvement in areas like memory management, error handling, and code duplication. + +--- + +## ๐ŸŽฏ Strengths + +### 1. **Architecture & Design Patterns** +- **State Pattern Implementation**: Clean state management with `MenuState`, `PlayingState`, `OptionsState`, `LevelSelectorState`, and `LoadingState` +- **Separation of Concerns**: Game logic (`Game.cpp`), rendering (`GameRenderer`, `UIRenderer`), audio (`Audio`, `SoundEffect`), and persistence (`Scores`) are well-separated +- **Centralized Configuration**: `Config.h` provides a single source of truth for constants, eliminating magic numbers +- **Service Locator Pattern**: `StateContext` acts as a dependency injection container + +### 2. **Modern C++ Practices** +- **C++20 Standard**: Using modern features like `std::filesystem`, `std::jthread` +- **RAII**: Proper resource management with smart pointers and automatic cleanup +- **Type Safety**: Strong typing with enums (`PieceType`, `AppState`, `LevelBackgroundPhase`) +- **Const Correctness**: Good use of `const` methods and references + +### 3. **Code Organization** +``` +src/ +โ”œโ”€โ”€ audio/ # Audio system (music, sound effects) +โ”œโ”€โ”€ core/ # Core systems (state management, settings, global state) +โ”œโ”€โ”€ gameplay/ # Game logic (Tetris mechanics, effects) +โ”œโ”€โ”€ graphics/ # Rendering (UI, game renderer, effects) +โ”œโ”€โ”€ persistence/ # Score management +โ”œโ”€โ”€ states/ # State implementations +โ””โ”€โ”€ utils/ # Utilities +``` +This structure is logical and easy to navigate. + +### 4. **Build System** +- **CMake**: Modern CMake with proper target configuration +- **vcpkg**: Excellent dependency management +- **Cross-platform**: Support for Windows and macOS +- **Testing**: Catch2 integration for unit tests + +--- + +## โš ๏ธ Areas for Improvement + +### 1. **Memory Management Issues** + +#### **Problem: Raw Pointer Usage** +**Location:** `MenuState.h`, `main.cpp` +```cpp +// MenuState.h (lines 17-21) +SDL_Texture* playIcon = nullptr; +SDL_Texture* levelIcon = nullptr; +SDL_Texture* optionsIcon = nullptr; +SDL_Texture* exitIcon = nullptr; +``` + +**Issue:** Raw pointers to SDL resources without proper cleanup in all code paths. + +**Recommendation:** +```cpp +// Create a smart pointer wrapper for SDL_Texture +struct SDL_TextureDeleter { + void operator()(SDL_Texture* tex) const { + if (tex) SDL_DestroyTexture(tex); + } +}; +using SDL_TexturePtr = std::unique_ptr; + +// Usage in MenuState.h +private: + SDL_TexturePtr playIcon; + SDL_TexturePtr levelIcon; + SDL_TexturePtr optionsIcon; + SDL_TexturePtr exitIcon; +``` + +**Benefits:** +- Automatic cleanup +- Exception safety +- No manual memory management +- Clear ownership semantics + +--- + +### 2. **Error Handling** + +#### **Problem: Inconsistent Error Handling** +**Location:** `main.cpp` (lines 86-114) +```cpp +static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) { + if (!renderer) { + return nullptr; // Silent failure + } + + SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); + if (!surface) { + SDL_LogError(...); // Logs but returns nullptr + return nullptr; + } + // ... +} +``` + +**Issues:** +- Silent failures make debugging difficult +- Callers must check for `nullptr` (easy to forget) +- No way to distinguish between different error types + +**Recommendation:** +```cpp +#include // C++23, or use tl::expected for C++20 + +struct TextureLoadError { + std::string message; + std::string path; +}; + +std::expected +loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, + int* outW = nullptr, int* outH = nullptr) { + if (!renderer) { + return std::unexpected(TextureLoadError{ + "Renderer is null", path + }); + } + + const std::string resolvedPath = AssetPath::resolveImagePath(path); + SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); + if (!surface) { + return std::unexpected(TextureLoadError{ + std::string("Failed to load: ") + SDL_GetError(), + resolvedPath + }); + } + + // ... success case + return SDL_TexturePtr(texture); +} + +// Usage: +auto result = loadTextureFromImage(renderer, "path.png"); +if (result) { + // Use result.value() +} else { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Failed to load %s: %s", + result.error().path.c_str(), + result.error().message.c_str()); +} +``` + +--- + +### 3. **Code Duplication** + +#### **Problem: Repeated Patterns** +**Location:** `MenuState.cpp`, `PlayingState.cpp`, `OptionsState.cpp` + +Similar lambda patterns for exit popup handling: +```cpp +auto setExitSelection = [&](int value) { + if (ctx.exitPopupSelectedButton) { + *ctx.exitPopupSelectedButton = value; + } +}; + +auto getExitSelection = [&]() -> int { + return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1; +}; +``` + +**Recommendation:** +Create a helper class in `StateContext`: +```cpp +// StateContext.h +class ExitPopupHelper { +public: + ExitPopupHelper(int* selectedButton, bool* showPopup) + : m_selectedButton(selectedButton), m_showPopup(showPopup) {} + + void setSelection(int value) { + if (m_selectedButton) *m_selectedButton = value; + } + + int getSelection() const { + return m_selectedButton ? *m_selectedButton : 1; + } + + void show() { + if (m_showPopup) *m_showPopup = true; + } + + void hide() { + if (m_showPopup) *m_showPopup = false; + } + + bool isVisible() const { + return m_showPopup && *m_showPopup; + } + +private: + int* m_selectedButton; + bool* m_showPopup; +}; + +// Usage in states: +ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup); +exitPopup.setSelection(0); +if (exitPopup.isVisible()) { ... } +``` + +--- + +### 4. **Magic Numbers** + +#### **Problem: Some Magic Numbers Still Present** +**Location:** `MenuState.cpp` (lines 269-273) +```cpp +float btnW = 200.0f; // Fixed width to match background buttons +float btnH = 70.0f; // Fixed height to match background buttons +float btnX = LOGICAL_W * 0.5f + contentOffsetX; +float btnY = LOGICAL_H * 0.865f + contentOffsetY; +``` + +**Recommendation:** +Add to `Config.h`: +```cpp +namespace Config::UI { + constexpr float MENU_BUTTON_WIDTH = 200.0f; + constexpr float MENU_BUTTON_HEIGHT = 70.0f; + constexpr float MENU_BUTTON_Y_FRACTION = 0.865f; + constexpr float MENU_BUTTON_SPACING = 210.0f; +} +``` + +--- + +### 5. **File I/O for Debugging** + +#### **Problem: Debug Logging to Files** +**Location:** `MenuState.cpp` (lines 182-184, 195-203, etc.) +```cpp +FILE* f = fopen("tetris_trace.log", "a"); +if (f) { + fprintf(f, "MenuState::render entry\n"); + fclose(f); +} +``` + +**Issues:** +- File handles not checked properly +- No error handling +- Performance overhead in production +- Should use proper logging framework + +**Recommendation:** +```cpp +// Create a simple logger utility +// src/utils/Logger.h +#pragma once +#include +#include +#include + +class Logger { +public: + enum class Level { TRACE, DEBUG, INFO, WARN, ERROR }; + + static Logger& instance(); + + void setLevel(Level level) { m_level = level; } + void setFile(const std::string& path); + + template + void trace(const char* fmt, Args... args) { + log(Level::TRACE, fmt, args...); + } + + template + void debug(const char* fmt, Args... args) { + log(Level::DEBUG, fmt, args...); + } + +private: + Logger() = default; + + template + void log(Level level, const char* fmt, Args... args); + + Level m_level = Level::INFO; + std::ofstream m_file; + std::mutex m_mutex; +}; + +// Usage: +#ifdef DEBUG + Logger::instance().trace("MenuState::render entry"); +#endif +``` + +--- + +### 6. **Const Correctness** + +#### **Problem: Missing const in Some Places** +**Location:** `StateContext` and various state methods + +**Recommendation:** +```cpp +// State.h +class State { +public: + virtual void render(SDL_Renderer* renderer, float logicalScale, + SDL_Rect logicalVP) const = 0; // Add const + // Render shouldn't modify state +}; +``` + +--- + +### 7. **Thread Safety** + +#### **Problem: Potential Race Conditions** +**Location:** `Audio.cpp` - Background loading + +**Current:** +```cpp +std::vector tracks; +std::mutex tracksMutex; +``` + +**Recommendation:** +- Document thread safety guarantees +- Use `std::shared_mutex` for read-heavy operations +- Consider using lock-free data structures for performance-critical paths + +```cpp +// Audio.h +class Audio { +private: + std::vector tracks; + mutable std::shared_mutex tracksMutex; // Allow concurrent reads + +public: + // Read operation - shared lock + int getLoadedTrackCount() const { + std::shared_lock lock(tracksMutex); + return tracks.size(); + } + + // Write operation - exclusive lock + void addTrack(const std::string& path) { + std::unique_lock lock(tracksMutex); + tracks.push_back(loadTrack(path)); + } +}; +``` + +--- + +### 8. **Testing Coverage** + +#### **Current State:** +Only one test file: `tests/GravityTests.cpp` + +**Recommendation:** +Add comprehensive tests: +``` +tests/ +โ”œโ”€โ”€ GravityTests.cpp โœ… Exists +โ”œโ”€โ”€ GameLogicTests.cpp โŒ Missing +โ”œโ”€โ”€ ScoreManagerTests.cpp โŒ Missing +โ”œโ”€โ”€ StateTransitionTests.cpp โŒ Missing +โ””โ”€โ”€ AudioSystemTests.cpp โŒ Missing +``` + +**Example Test Structure:** +```cpp +// tests/GameLogicTests.cpp +#include +#include "gameplay/core/Game.h" + +TEST_CASE("Game initialization", "[game]") { + Game game(0); + + SECTION("Board starts empty") { + const auto& board = game.boardRef(); + REQUIRE(std::all_of(board.begin(), board.end(), + [](int cell) { return cell == 0; })); + } + + SECTION("Score starts at zero") { + REQUIRE(game.score() == 0); + REQUIRE(game.lines() == 0); + } +} + +TEST_CASE("Piece rotation", "[game]") { + Game game(0); + + SECTION("Clockwise rotation") { + auto initialRot = game.current().rot; + game.rotate(1); + REQUIRE(game.current().rot == (initialRot + 1) % 4); + } +} + +TEST_CASE("Line clearing", "[game]") { + Game game(0); + + SECTION("Single line clear awards correct score") { + // Setup: Fill bottom row except one cell + // ... test implementation + } +} +``` + +--- + +### 9. **Documentation** + +#### **Current State:** +- Good inline comments +- Config.h has excellent documentation +- Missing: API documentation, architecture overview + +**Recommendation:** +Add Doxygen-style comments: +```cpp +/** + * @class Game + * @brief Core Tetris game logic engine + * + * Manages the game board, piece spawning, collision detection, + * line clearing, and scoring. This class is independent of + * rendering and input handling. + * + * @note Thread-safe for read operations, but write operations + * (move, rotate, etc.) should only be called from the + * main game thread. + * + * Example usage: + * @code + * Game game(5); // Start at level 5 + * game.tickGravity(16.67); // Update for one frame + * if (game.isGameOver()) { + * // Handle game over + * } + * @endcode + */ +class Game { + // ... +}; +``` + +Create `docs/ARCHITECTURE.md`: +```markdown +# Architecture Overview + +## State Machine +[Diagram of state transitions] + +## Data Flow +[Diagram showing how data flows through the system] + +## Threading Model +- Main thread: Rendering, input, game logic +- Background thread: Audio loading +- Audio callback thread: Audio mixing +``` + +--- + +### 10. **Performance Considerations** + +#### **Issue: Frequent String Allocations** +**Location:** Various places using `std::string` for paths + +**Recommendation:** +```cpp +// Use string_view for read-only string parameters +#include + +SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, + std::string_view path, // Changed + int* outW = nullptr, + int* outH = nullptr); + +// For compile-time strings, use constexpr +namespace AssetPaths { + constexpr std::string_view LOGO = "assets/images/logo.bmp"; + constexpr std::string_view BACKGROUND = "assets/images/main_background.bmp"; +} +``` + +#### **Issue: Vector Reallocations** +**Location:** `fireworks` vector in `main.cpp` + +**Recommendation:** +```cpp +// Reserve capacity upfront +fireworks.reserve(5); // Max 5 fireworks at once + +// Or use a fixed-size container +std::array, 5> fireworks; +``` + +--- + +## ๐Ÿ”ง Specific Recommendations by Priority + +### **High Priority** (Do These First) + +1. **Replace raw SDL pointers with smart pointers** + - Impact: Prevents memory leaks + - Effort: Medium + - Files: `MenuState.h`, `main.cpp`, all state files + +2. **Remove debug file I/O from production code** + - Impact: Performance, code cleanliness + - Effort: Low + - Files: `MenuState.cpp`, `main.cpp` + +3. **Add error handling to asset loading** + - Impact: Better debugging, crash prevention + - Effort: Medium + - Files: `main.cpp`, `AssetManager.cpp` + +### **Medium Priority** + +4. **Extract common patterns into helper classes** + - Impact: Code maintainability + - Effort: Medium + - Files: All state files + +5. **Move remaining magic numbers to Config.h** + - Impact: Maintainability + - Effort: Low + - Files: `MenuState.cpp`, `UIRenderer.cpp` + +6. **Add comprehensive unit tests** + - Impact: Code quality, regression prevention + - Effort: High + - Files: New test files + +### **Low Priority** (Nice to Have) + +7. **Add Doxygen documentation** + - Impact: Developer onboarding + - Effort: Medium + +8. **Performance profiling and optimization** + - Impact: Depends on current performance + - Effort: Medium + +9. **Consider using `std::expected` for error handling** + - Impact: Better error handling + - Effort: High (requires C++23 or external library) + +--- + +## ๐Ÿ“ Code Style Observations + +### **Good Practices You're Already Following:** + +โœ… **Consistent naming conventions:** +- Classes: `PascalCase` (e.g., `MenuState`, `GameRenderer`) +- Functions: `camelCase` (e.g., `tickGravity`, `loadTexture`) +- Constants: `UPPER_SNAKE_CASE` (e.g., `LOGICAL_W`, `DAS_DELAY`) +- Member variables: `camelCase` with `m_` prefix in some places + +โœ… **Header guards:** Using `#pragma once` + +โœ… **Forward declarations:** Minimizing include dependencies + +โœ… **RAII:** Resources tied to object lifetime + +### **Minor Style Inconsistencies:** + +โŒ **Inconsistent member variable naming:** +```cpp +// Some classes use m_ prefix +float m_masterVolume = 1.0f; + +// Others don't +int selectedButton = 0; +``` + +**Recommendation:** Pick one style and stick to it. I suggest: +```cpp +// Private members: m_ prefix +float m_masterVolume = 1.0f; +int m_selectedButton = 0; + +// Public members: no prefix (rare in good design) +``` + +--- + +## ๐ŸŽจ Architecture Suggestions + +### **Consider Implementing:** + +1. **Event System** + Instead of callbacks, use an event bus: + ```cpp + // events/GameEvents.h + struct LineClearedEvent { + int linesCleared; + int newScore; + }; + + struct LevelUpEvent { + int newLevel; + }; + + // EventBus.h + class EventBus { + public: + template + void subscribe(std::function handler); + + template + void publish(const Event& event); + }; + + // Usage in Game.cpp + eventBus.publish(LineClearedEvent{linesCleared, _score}); + + // Usage in Audio system + eventBus.subscribe([](const auto& e) { + playLineClearSound(e.linesCleared); + }); + ``` + +2. **Component-Based UI** + Extract UI components: + ```cpp + class Button { + public: + void render(SDL_Renderer* renderer); + bool isHovered(int mouseX, int mouseY) const; + void onClick(std::function callback); + }; + + class Panel { + std::vector> children; + }; + ``` + +3. **Asset Manager** + Centralize asset loading: + ```cpp + class AssetManager { + public: + SDL_TexturePtr getTexture(std::string_view name); + FontAtlas* getFont(std::string_view name); + + private: + std::unordered_map textures; + std::unordered_map> fonts; + }; + ``` + +--- + +## ๐Ÿ” Security Considerations + +1. **File Path Validation** + ```cpp + // AssetPath::resolveImagePath should validate paths + // to prevent directory traversal attacks + std::string resolveImagePath(std::string_view path) { + // Reject paths with ".." + if (path.find("..") != std::string_view::npos) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Invalid path: %s", path.data()); + return ""; + } + // ... rest of implementation + } + ``` + +2. **Score File Tampering** + Consider adding checksums to score files: + ```cpp + // Scores.cpp + void ScoreManager::save() const { + nlohmann::json j; + j["scores"] = scores; + j["checksum"] = computeChecksum(scores); + // ... save to file + } + ``` + +--- + +## ๐Ÿ“Š Metrics + +Based on the codebase analysis: + +| Metric | Value | Rating | +|--------|-------|--------| +| **Code Organization** | Excellent | โญโญโญโญโญ | +| **Modern C++ Usage** | Very Good | โญโญโญโญ | +| **Error Handling** | Fair | โญโญโญ | +| **Memory Safety** | Good | โญโญโญโญ | +| **Test Coverage** | Poor | โญ | +| **Documentation** | Good | โญโญโญโญ | +| **Performance** | Good | โญโญโญโญ | +| **Maintainability** | Very Good | โญโญโญโญ | + +**Overall Score: 4/5 โญโญโญโญ** + +--- + +## ๐Ÿš€ Quick Wins (Easy Improvements) + +1. **Add `.clang-format` file** for consistent formatting +2. **Create `CONTRIBUTING.md`** with coding guidelines +3. **Add pre-commit hooks** for formatting and linting +4. **Set up GitHub Actions** for CI/CD +5. **Add `README.md`** with build instructions and screenshots + +--- + +## ๐Ÿ“š Recommended Resources + +- **Modern C++ Best Practices:** https://isocpp.github.io/CppCoreGuidelines/ +- **SDL3 Migration Guide:** https://wiki.libsdl.org/SDL3/README/migration +- **Game Programming Patterns:** https://gameprogrammingpatterns.com/ +- **C++ Testing with Catch2:** https://github.com/catchorg/Catch2 + +--- + +## โœ… Conclusion + +Your Tetris project demonstrates **strong software engineering practices** with a clean architecture, modern C++ usage, and good separation of concerns. The main areas for improvement are: + +1. Enhanced error handling +2. Increased test coverage +3. Elimination of raw pointers +4. Removal of debug code from production + +With these improvements, this codebase would be **production-ready** and serve as an excellent example of modern C++ game development. + +**Keep up the excellent work!** ๐ŸŽฎ diff --git a/IMPROVEMENTS_CHECKLIST.md b/IMPROVEMENTS_CHECKLIST.md new file mode 100644 index 0000000..acf363f --- /dev/null +++ b/IMPROVEMENTS_CHECKLIST.md @@ -0,0 +1,363 @@ +# Tetris SDL3 - Improvements Checklist + +Quick reference for implementing the recommendations from CODE_ANALYSIS.md + +--- + +## ๐Ÿ”ด High Priority (Critical) + +### 1. Smart Pointer Wrapper for SDL Resources +**Status:** โŒ Not Started +**Effort:** 2-3 hours +**Impact:** Prevents memory leaks, improves safety + +**Action Items:** +- [ ] Create `src/utils/SDLPointers.h` with smart pointer wrappers +- [ ] Replace raw `SDL_Texture*` in `MenuState.h` (lines 17-21) +- [ ] Replace raw `SDL_Texture*` in `PlayingState.h` +- [ ] Update `main.cpp` texture loading +- [ ] Test all states to ensure no regressions + +**Code Template:** +```cpp +// src/utils/SDLPointers.h +#pragma once +#include +#include + +struct SDL_TextureDeleter { + void operator()(SDL_Texture* tex) const { + if (tex) SDL_DestroyTexture(tex); + } +}; + +struct SDL_SurfaceDeleter { + void operator()(SDL_Surface* surf) const { + if (surf) SDL_DestroySurface(surf); + } +}; + +using SDL_TexturePtr = std::unique_ptr; +using SDL_SurfacePtr = std::unique_ptr; +``` + +--- + +### 2. Remove Debug File I/O +**Status:** โŒ Not Started +**Effort:** 30 minutes +**Impact:** Performance, code cleanliness + +**Action Items:** +- [ ] Remove or wrap `fopen("tetris_trace.log")` calls in `MenuState.cpp` +- [ ] Remove or wrap similar calls in other files +- [ ] Replace with SDL_LogTrace or conditional compilation +- [ ] Delete `tetris_trace.log` from repository + +**Files to Update:** +- `src/states/MenuState.cpp` (lines 182-184, 195-203, 277-278, 335-337) +- `src/main.cpp` (if any similar patterns exist) + +**Replacement Pattern:** +```cpp +// Before: +FILE* f = fopen("tetris_trace.log", "a"); +if (f) { fprintf(f, "MenuState::render entry\n"); fclose(f); } + +// After: +SDL_LogTrace(SDL_LOG_CATEGORY_APPLICATION, "MenuState::render entry"); +``` + +--- + +### 3. Improve Error Handling in Asset Loading +**Status:** โŒ Not Started +**Effort:** 2 hours +**Impact:** Better debugging, prevents crashes + +**Action Items:** +- [ ] Update `loadTextureFromImage` to return error information +- [ ] Add validation for all asset loads in `main.cpp` +- [ ] Create fallback assets for missing resources +- [ ] Add startup asset validation + +**Example:** +```cpp +struct AssetLoadResult { + SDL_TexturePtr texture; + std::string error; + bool success; +}; + +AssetLoadResult loadTextureFromImage(SDL_Renderer* renderer, + const std::string& path); +``` + +--- + +## ๐ŸŸก Medium Priority (Important) + +### 4. Extract Common Patterns +**Status:** โŒ Not Started +**Effort:** 3-4 hours +**Impact:** Reduces code duplication + +**Action Items:** +- [ ] Create `ExitPopupHelper` class in `StateContext.h` +- [ ] Update `MenuState.cpp` to use helper +- [ ] Update `PlayingState.cpp` to use helper +- [ ] Update `OptionsState.cpp` to use helper + +--- + +### 5. Move Magic Numbers to Config.h +**Status:** โŒ Not Started +**Effort:** 1 hour +**Impact:** Maintainability + +**Action Items:** +- [ ] Add menu button constants to `Config::UI` +- [ ] Add rendering constants to appropriate namespace +- [ ] Update `MenuState.cpp` to use config constants +- [ ] Update `UIRenderer.cpp` to use config constants + +**Constants to Add:** +```cpp +namespace Config::UI { + constexpr float MENU_BUTTON_WIDTH = 200.0f; + constexpr float MENU_BUTTON_HEIGHT = 70.0f; + constexpr float MENU_BUTTON_Y_FRACTION = 0.865f; + constexpr float MENU_BUTTON_SPACING = 210.0f; +} +``` + +--- + +### 6. Add Unit Tests +**Status:** โš ๏ธ Minimal (only GravityTests) +**Effort:** 8-10 hours +**Impact:** Code quality, regression prevention + +**Action Items:** +- [ ] Create `tests/GameLogicTests.cpp` + - [ ] Test piece spawning + - [ ] Test rotation + - [ ] Test collision detection + - [ ] Test line clearing + - [ ] Test scoring +- [ ] Create `tests/ScoreManagerTests.cpp` + - [ ] Test score submission + - [ ] Test high score detection + - [ ] Test persistence +- [ ] Create `tests/StateTransitionTests.cpp` + - [ ] Test state transitions + - [ ] Test state lifecycle (onEnter/onExit) +- [ ] Update CMakeLists.txt to include new tests + +--- + +## ๐ŸŸข Low Priority (Nice to Have) + +### 7. Add Doxygen Documentation +**Status:** โŒ Not Started +**Effort:** 4-6 hours +**Impact:** Developer onboarding + +**Action Items:** +- [ ] Create `Doxyfile` configuration +- [ ] Add class-level documentation to core classes +- [ ] Add function-level documentation to public APIs +- [ ] Generate HTML documentation +- [ ] Add to build process + +--- + +### 8. Performance Profiling +**Status:** โŒ Not Started +**Effort:** 4-6 hours +**Impact:** Depends on findings + +**Action Items:** +- [ ] Profile with Visual Studio Profiler / Instruments +- [ ] Identify hotspots +- [ ] Optimize critical paths +- [ ] Add performance benchmarks + +--- + +### 9. Standardize Member Variable Naming +**Status:** โš ๏ธ Inconsistent +**Effort:** 2-3 hours +**Impact:** Code consistency + +**Action Items:** +- [ ] Decide on naming convention (recommend `m_` prefix for private members) +- [ ] Update all class member variables +- [ ] Update documentation to reflect convention + +**Convention Recommendation:** +```cpp +class Example { +public: + int publicValue; // No prefix for public members + +private: + int m_privateValue; // m_ prefix for private members + float m_memberVariable; // Consistent across all classes +}; +``` + +--- + +## ๐Ÿ“‹ Code Quality Improvements + +### 10. Add .clang-format +**Status:** โŒ Not Started +**Effort:** 15 minutes +**Impact:** Consistent formatting + +**Action Items:** +- [ ] Create `.clang-format` file in project root +- [ ] Run formatter on all source files +- [ ] Add format check to CI/CD + +**Suggested .clang-format:** +```yaml +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 120 +PointerAlignment: Left +AllowShortFunctionsOnASingleLine: Empty +``` + +--- + +### 11. Add README.md +**Status:** โŒ Missing +**Effort:** 1 hour +**Impact:** Project documentation + +**Action Items:** +- [ ] Create `README.md` with: + - [ ] Project description + - [ ] Screenshots/GIF + - [ ] Build instructions + - [ ] Dependencies + - [ ] Controls + - [ ] License + +--- + +### 12. Set Up CI/CD +**Status:** โŒ Not Started +**Effort:** 2-3 hours +**Impact:** Automated testing + +**Action Items:** +- [ ] Create `.github/workflows/build.yml` +- [ ] Add Windows build job +- [ ] Add macOS build job +- [ ] Add test execution +- [ ] Add artifact upload + +--- + +## ๐Ÿ”ง Refactoring Opportunities + +### 13. Create Asset Manager +**Status:** โŒ Not Started +**Effort:** 4-5 hours +**Impact:** Better resource management + +**Action Items:** +- [ ] Create `src/core/assets/AssetManager.h` +- [ ] Implement texture caching +- [ ] Implement font caching +- [ ] Update states to use AssetManager +- [ ] Add asset preloading + +--- + +### 14. Implement Event System +**Status:** โŒ Not Started +**Effort:** 6-8 hours +**Impact:** Decoupling, flexibility + +**Action Items:** +- [ ] Create `src/core/events/EventBus.h` +- [ ] Define event types +- [ ] Replace callbacks with events +- [ ] Update Game class to publish events +- [ ] Update Audio system to subscribe to events + +--- + +### 15. Component-Based UI +**Status:** โŒ Not Started +**Effort:** 8-10 hours +**Impact:** UI maintainability + +**Action Items:** +- [ ] Create `src/ui/components/Button.h` +- [ ] Create `src/ui/components/Panel.h` +- [ ] Create `src/ui/components/Label.h` +- [ ] Refactor MenuState to use components +- [ ] Refactor OptionsState to use components + +--- + +## ๐Ÿ“Š Progress Tracking + +| Category | Total Items | Completed | In Progress | Not Started | +|----------|-------------|-----------|-------------|-------------| +| High Priority | 3 | 0 | 0 | 3 | +| Medium Priority | 3 | 0 | 0 | 3 | +| Low Priority | 3 | 0 | 0 | 3 | +| Code Quality | 3 | 0 | 0 | 3 | +| Refactoring | 3 | 0 | 0 | 3 | +| **TOTAL** | **15** | **0** | **0** | **15** | + +--- + +## ๐ŸŽฏ Suggested Implementation Order + +### Week 1: Critical Fixes +1. Remove debug file I/O (30 min) +2. Smart pointer wrapper (2-3 hours) +3. Improve error handling (2 hours) + +### Week 2: Code Quality +4. Move magic numbers to Config.h (1 hour) +5. Extract common patterns (3-4 hours) +6. Add .clang-format (15 min) +7. Add README.md (1 hour) + +### Week 3: Testing +8. Add GameLogicTests (4 hours) +9. Add ScoreManagerTests (2 hours) +10. Add StateTransitionTests (2 hours) + +### Week 4: Documentation & CI +11. Set up CI/CD (2-3 hours) +12. Add Doxygen documentation (4-6 hours) + +### Future Iterations: +13. Performance profiling +14. Asset Manager +15. Event System +16. Component-Based UI + +--- + +## ๐Ÿ“ Notes + +- Mark items as completed by changing โŒ to โœ… +- Update progress table as you complete items +- Feel free to reorder based on your priorities +- Some items can be done in parallel +- Consider creating GitHub issues for tracking + +--- + +**Last Updated:** 2025-12-03 +**Next Review:** After completing High Priority items diff --git a/QUICK_START_IMPROVEMENTS.md b/QUICK_START_IMPROVEMENTS.md new file mode 100644 index 0000000..764c369 --- /dev/null +++ b/QUICK_START_IMPROVEMENTS.md @@ -0,0 +1,774 @@ +# Quick Start: Implementing Top 3 Improvements + +This guide provides complete, copy-paste ready code for the three most impactful improvements. + +--- + +## ๐Ÿš€ Improvement #1: Smart Pointer Wrapper for SDL Resources + +### Step 1: Create the Utility Header + +**File:** `src/utils/SDLPointers.h` + +```cpp +#pragma once +#include +#include + +/** + * @file SDLPointers.h + * @brief Smart pointer wrappers for SDL resources + * + * Provides RAII wrappers for SDL resources to prevent memory leaks + * and ensure proper cleanup in all code paths. + */ + +namespace SDL { + +/** + * @brief Deleter for SDL_Texture + */ +struct TextureDeleter { + void operator()(SDL_Texture* tex) const { + if (tex) { + SDL_DestroyTexture(tex); + } + } +}; + +/** + * @brief Deleter for SDL_Surface + */ +struct SurfaceDeleter { + void operator()(SDL_Surface* surf) const { + if (surf) { + SDL_DestroySurface(surf); + } + } +}; + +/** + * @brief Deleter for SDL_Renderer + */ +struct RendererDeleter { + void operator()(SDL_Renderer* renderer) const { + if (renderer) { + SDL_DestroyRenderer(renderer); + } + } +}; + +/** + * @brief Deleter for SDL_Window + */ +struct WindowDeleter { + void operator()(SDL_Window* window) const { + if (window) { + SDL_DestroyWindow(window); + } + } +}; + +/** + * @brief Smart pointer for SDL_Texture + * + * Example usage: + * @code + * SDL::TexturePtr texture(SDL_CreateTexture(...)); + * if (!texture) { + * // Handle error + * } + * // Automatic cleanup when texture goes out of scope + * @endcode + */ +using TexturePtr = std::unique_ptr; + +/** + * @brief Smart pointer for SDL_Surface + */ +using SurfacePtr = std::unique_ptr; + +/** + * @brief Smart pointer for SDL_Renderer + */ +using RendererPtr = std::unique_ptr; + +/** + * @brief Smart pointer for SDL_Window + */ +using WindowPtr = std::unique_ptr; + +} // namespace SDL +``` + +--- + +### Step 2: Update MenuState.h + +**File:** `src/states/MenuState.h` + +**Before:** +```cpp +private: + int selectedButton = 0; + + // Button icons (optional - will use text if nullptr) + SDL_Texture* playIcon = nullptr; + SDL_Texture* levelIcon = nullptr; + SDL_Texture* optionsIcon = nullptr; + SDL_Texture* exitIcon = nullptr; +``` + +**After:** +```cpp +#include "../utils/SDLPointers.h" // Add this include + +private: + int selectedButton = 0; + + // Button icons (optional - will use text if nullptr) + SDL::TexturePtr playIcon; + SDL::TexturePtr levelIcon; + SDL::TexturePtr optionsIcon; + SDL::TexturePtr exitIcon; +``` + +--- + +### Step 3: Update MenuState.cpp + +**File:** `src/states/MenuState.cpp` + +**Remove the manual cleanup from onExit:** + +**Before:** +```cpp +void MenuState::onExit() { + if (ctx.showExitConfirmPopup) { + *ctx.showExitConfirmPopup = false; + } + + // Clean up icon textures + if (playIcon) { SDL_DestroyTexture(playIcon); playIcon = nullptr; } + if (levelIcon) { SDL_DestroyTexture(levelIcon); levelIcon = nullptr; } + if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; } + if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; } +} +``` + +**After:** +```cpp +void MenuState::onExit() { + if (ctx.showExitConfirmPopup) { + *ctx.showExitConfirmPopup = false; + } + + // Icon textures are automatically cleaned up by smart pointers +} +``` + +**Update usage in render method:** + +**Before:** +```cpp +std::array icons = { + playIcon, + levelIcon, + optionsIcon, + exitIcon +}; +``` + +**After:** +```cpp +std::array icons = { + playIcon.get(), + levelIcon.get(), + optionsIcon.get(), + exitIcon.get() +}; +``` + +--- + +### Step 4: Update main.cpp Texture Loading + +**File:** `src/main.cpp` + +**Update the function signature and implementation:** + +**Before:** +```cpp +static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) { + if (!renderer) { + return nullptr; + } + + const std::string resolvedPath = AssetPath::resolveImagePath(path); + SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); + if (!surface) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + if (outW) { *outW = surface->w; } + if (outH) { *outH = surface->h; } + + SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_DestroySurface(surface); + + if (!texture) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + if (resolvedPath != path) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str()); + } + + return texture; +} +``` + +**After:** +```cpp +#include "utils/SDLPointers.h" // Add at top of file + +static SDL::TexturePtr loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) { + if (!renderer) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Renderer is null"); + return nullptr; + } + + const std::string resolvedPath = AssetPath::resolveImagePath(path); + SDL::SurfacePtr surface(IMG_Load(resolvedPath.c_str())); + if (!surface) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", + path.c_str(), resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + if (outW) { *outW = surface->w; } + if (outH) { *outH = surface->h; } + + SDL::TexturePtr texture(SDL_CreateTextureFromSurface(renderer, surface.get())); + // surface is automatically destroyed here + + if (!texture) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", + resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + if (resolvedPath != path) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str()); + } + + return texture; +} +``` + +--- + +## ๐Ÿงน Improvement #2: Remove Debug File I/O + +### Step 1: Replace with SDL Logging + +**File:** `src/states/MenuState.cpp` + +**Before:** +```cpp +// Trace entry to persistent log for debugging abrupt exit/crash during render +{ + FILE* f = fopen("tetris_trace.log", "a"); + if (f) { + fprintf(f, "MenuState::render entry\n"); + fclose(f); + } +} +``` + +**After:** +```cpp +// Use SDL's built-in logging (only in debug builds) +#ifdef _DEBUG + SDL_LogTrace(SDL_LOG_CATEGORY_APPLICATION, "MenuState::render entry"); +#endif +``` + +**Or, if you want it always enabled but less verbose:** +```cpp +SDL_LogVerbose(SDL_LOG_CATEGORY_APPLICATION, "MenuState::render entry"); +``` + +--- + +### Step 2: Create a Logging Utility (Optional, Better Approach) + +**File:** `src/utils/Logger.h` + +```cpp +#pragma once +#include + +/** + * @brief Centralized logging utility + * + * Wraps SDL logging with compile-time control over verbosity. + */ +namespace Logger { + +#ifdef _DEBUG + constexpr bool TRACE_ENABLED = true; +#else + constexpr bool TRACE_ENABLED = false; +#endif + +/** + * @brief Log a trace message (only in debug builds) + */ +template +inline void trace(const char* fmt, Args... args) { + if constexpr (TRACE_ENABLED) { + SDL_LogTrace(SDL_LOG_CATEGORY_APPLICATION, fmt, args...); + } +} + +/** + * @brief Log a debug message + */ +template +inline void debug(const char* fmt, Args... args) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, fmt, args...); +} + +/** + * @brief Log an info message + */ +template +inline void info(const char* fmt, Args... args) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, fmt, args...); +} + +/** + * @brief Log a warning message + */ +template +inline void warn(const char* fmt, Args... args) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, fmt, args...); +} + +/** + * @brief Log an error message + */ +template +inline void error(const char* fmt, Args... args) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, fmt, args...); +} + +} // namespace Logger +``` + +**Usage in MenuState.cpp:** +```cpp +#include "../utils/Logger.h" + +void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { + Logger::trace("MenuState::render entry"); + + // ... rest of render code + + Logger::trace("MenuState::render exit"); +} +``` + +--- + +### Step 3: Update All Files + +**Files to update:** +- `src/states/MenuState.cpp` (multiple locations) +- `src/main.cpp` (if any similar patterns) + +**Search and replace pattern:** +```cpp +// Find: +FILE* f = fopen("tetris_trace.log", "a"); +if (f) { + fprintf(f, ".*"); + fclose(f); +} + +// Replace with: +Logger::trace("..."); +``` + +--- + +## ๐ŸŽฏ Improvement #3: Extract Common Patterns + +### Step 1: Create ExitPopupHelper + +**File:** `src/states/StateHelpers.h` (new file) + +```cpp +#pragma once + +/** + * @file StateHelpers.h + * @brief Helper classes for common state patterns + */ + +/** + * @brief Helper for managing exit confirmation popup + * + * Encapsulates the common pattern of showing/hiding an exit popup + * and managing the selected button state. + * + * Example usage: + * @code + * ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup); + * + * if (exitPopup.isVisible()) { + * exitPopup.setSelection(0); // Select YES + * } + * + * if (exitPopup.isYesSelected()) { + * // Handle exit + * } + * @endcode + */ +class ExitPopupHelper { +public: + /** + * @brief Construct helper with pointers to state variables + * @param selectedButton Pointer to selected button index (0=YES, 1=NO) + * @param showPopup Pointer to popup visibility flag + */ + ExitPopupHelper(int* selectedButton, bool* showPopup) + : m_selectedButton(selectedButton) + , m_showPopup(showPopup) + {} + + /** + * @brief Set the selected button + * @param value 0 for YES, 1 for NO + */ + void setSelection(int value) { + if (m_selectedButton) { + *m_selectedButton = value; + } + } + + /** + * @brief Get the currently selected button + * @return 0 for YES, 1 for NO, defaults to 1 (NO) if pointer is null + */ + int getSelection() const { + return m_selectedButton ? *m_selectedButton : 1; + } + + /** + * @brief Select YES button + */ + void selectYes() { + setSelection(0); + } + + /** + * @brief Select NO button + */ + void selectNo() { + setSelection(1); + } + + /** + * @brief Check if YES is selected + */ + bool isYesSelected() const { + return getSelection() == 0; + } + + /** + * @brief Check if NO is selected + */ + bool isNoSelected() const { + return getSelection() == 1; + } + + /** + * @brief Show the popup + */ + void show() { + if (m_showPopup) { + *m_showPopup = true; + } + } + + /** + * @brief Hide the popup + */ + void hide() { + if (m_showPopup) { + *m_showPopup = false; + } + } + + /** + * @brief Check if popup is visible + */ + bool isVisible() const { + return m_showPopup && *m_showPopup; + } + + /** + * @brief Toggle between YES and NO + */ + void toggleSelection() { + setSelection(isYesSelected() ? 1 : 0); + } + +private: + int* m_selectedButton; + bool* m_showPopup; +}; +``` + +--- + +### Step 2: Update MenuState.cpp + +**File:** `src/states/MenuState.cpp` + +**Add include:** +```cpp +#include "StateHelpers.h" +``` + +**Before:** +```cpp +void MenuState::handleEvent(const SDL_Event& e) { + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + auto setExitSelection = [&](int value) { + if (ctx.exitPopupSelectedButton) { + *ctx.exitPopupSelectedButton = value; + } + }; + auto getExitSelection = [&]() -> int { + return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1; + }; + auto isExitPromptVisible = [&]() -> bool { + return ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup; + }; + auto setExitPrompt = [&](bool visible) { + if (ctx.showExitConfirmPopup) { + *ctx.showExitConfirmPopup = visible; + } + }; + + if (isExitPromptVisible()) { + switch (e.key.scancode) { + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_UP: + setExitSelection(0); + return; + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_DOWN: + setExitSelection(1); + return; + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + if (getExitSelection() == 0) { + setExitPrompt(false); + if (ctx.requestQuit) { + ctx.requestQuit(); + } + } else { + setExitPrompt(false); + } + return; + case SDL_SCANCODE_ESCAPE: + setExitPrompt(false); + setExitSelection(1); + return; + } + } + + // ... rest of code + } +} +``` + +**After:** +```cpp +void MenuState::handleEvent(const SDL_Event& e) { + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup); + + auto triggerPlay = [&]() { + if (ctx.startPlayTransition) { + ctx.startPlayTransition(); + } else if (ctx.stateManager) { + ctx.stateManager->setState(AppState::Playing); + } + }; + + if (exitPopup.isVisible()) { + switch (e.key.scancode) { + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_UP: + exitPopup.selectYes(); + return; + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_DOWN: + exitPopup.selectNo(); + return; + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + if (exitPopup.isYesSelected()) { + exitPopup.hide(); + if (ctx.requestQuit) { + ctx.requestQuit(); + } else { + SDL_Event quit{}; + quit.type = SDL_EVENT_QUIT; + SDL_PushEvent(&quit); + } + } else { + exitPopup.hide(); + } + return; + case SDL_SCANCODE_ESCAPE: + exitPopup.hide(); + exitPopup.selectNo(); + return; + default: + return; + } + } + + switch (e.key.scancode) { + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_UP: + { + const int total = 4; + selectedButton = (selectedButton + total - 1) % total; + break; + } + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_DOWN: + { + const int total = 4; + selectedButton = (selectedButton + 1) % total; + break; + } + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + if (!ctx.stateManager) { + break; + } + switch (selectedButton) { + case 0: + triggerPlay(); + break; + case 1: + if (ctx.requestFadeTransition) { + ctx.requestFadeTransition(AppState::LevelSelector); + } else if (ctx.stateManager) { + ctx.stateManager->setState(AppState::LevelSelector); + } + break; + case 2: + if (ctx.requestFadeTransition) { + ctx.requestFadeTransition(AppState::Options); + } else if (ctx.stateManager) { + ctx.stateManager->setState(AppState::Options); + } + break; + case 3: + exitPopup.show(); + exitPopup.selectNo(); + break; + } + break; + case SDL_SCANCODE_ESCAPE: + exitPopup.show(); + exitPopup.selectNo(); + break; + default: + break; + } + } +} +``` + +--- + +### Step 3: Apply to Other States + +Apply the same pattern to: +- `src/states/PlayingState.cpp` +- `src/states/OptionsState.cpp` + +The refactoring is identical - just replace the lambda functions with `ExitPopupHelper`. + +--- + +## โœ… Testing Your Changes + +After implementing these improvements: + +1. **Build the project:** + ```powershell + cd d:\Sites\Work\tetris + cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug + cmake --build build + ``` + +2. **Run the game:** + ```powershell + .\build\Debug\tetris.exe + ``` + +3. **Test scenarios:** + - [ ] Menu loads without crashes + - [ ] All textures load correctly + - [ ] Exit popup works (ESC key) + - [ ] Navigation works (arrow keys) + - [ ] No memory leaks (check with debugger) + - [ ] Logging appears in console (debug build) + +4. **Check for memory leaks:** + - Run with Visual Studio debugger + - Check Output window for memory leak reports + - Should see no leaks from SDL textures + +--- + +## ๐Ÿ“Š Expected Impact + +After implementing these three improvements: + +| Metric | Before | After | Improvement | +|--------|--------|-------|-------------| +| **Memory Safety** | โญโญโญ | โญโญโญโญโญ | +67% | +| **Code Clarity** | โญโญโญโญ | โญโญโญโญโญ | +25% | +| **Maintainability** | โญโญโญโญ | โญโญโญโญโญ | +25% | +| **Lines of Code** | 100% | ~95% | -5% | +| **Potential Bugs** | Medium | Low | -50% | + +--- + +## ๐ŸŽ‰ Next Steps + +After successfully implementing these improvements: + +1. Review the full `CODE_ANALYSIS.md` for more recommendations +2. Check `IMPROVEMENTS_CHECKLIST.md` for the complete task list +3. Consider implementing the medium-priority items next +4. Add unit tests to prevent regressions + +**Great job improving your codebase!** ๐Ÿš€ diff --git a/assets/fonts/Exo2.ttf b/assets/fonts/Exo2.ttf new file mode 100644 index 0000000..2170b15 Binary files /dev/null and b/assets/fonts/Exo2.ttf differ diff --git a/assets/fonts/Orbitron.ttf b/assets/fonts/Orbitron.ttf new file mode 100644 index 0000000..7d0d5e0 Binary files /dev/null and b/assets/fonts/Orbitron.ttf differ diff --git a/assets/images/earth_back.jpg b/assets/images/earth_back.jpg new file mode 100644 index 0000000..c9727b3 Binary files /dev/null and b/assets/images/earth_back.jpg differ diff --git a/assets/images/levels/level0.jpg b/assets/images/levels/level0.jpg new file mode 100644 index 0000000..c2a0cf5 Binary files /dev/null and b/assets/images/levels/level0.jpg differ diff --git a/assets/images/levels/level1.jpg b/assets/images/levels/level1.jpg new file mode 100644 index 0000000..43d10a7 Binary files /dev/null and b/assets/images/levels/level1.jpg differ diff --git a/assets/images/levels/level10.jpg b/assets/images/levels/level10.jpg new file mode 100644 index 0000000..8b62e21 Binary files /dev/null and b/assets/images/levels/level10.jpg differ diff --git a/assets/images/levels/level11.jpg b/assets/images/levels/level11.jpg new file mode 100644 index 0000000..1588979 Binary files /dev/null and b/assets/images/levels/level11.jpg differ diff --git a/assets/images/levels/level12.jpg b/assets/images/levels/level12.jpg new file mode 100644 index 0000000..f553a7e Binary files /dev/null and b/assets/images/levels/level12.jpg differ diff --git a/assets/images/levels/level13.jpg b/assets/images/levels/level13.jpg new file mode 100644 index 0000000..f505cf4 Binary files /dev/null and b/assets/images/levels/level13.jpg differ diff --git a/assets/images/levels/level14.jpg b/assets/images/levels/level14.jpg new file mode 100644 index 0000000..1c9801a Binary files /dev/null and b/assets/images/levels/level14.jpg differ diff --git a/assets/images/levels/level15.jpg b/assets/images/levels/level15.jpg new file mode 100644 index 0000000..4de466b Binary files /dev/null and b/assets/images/levels/level15.jpg differ diff --git a/assets/images/levels/level16.jpg b/assets/images/levels/level16.jpg new file mode 100644 index 0000000..256393d Binary files /dev/null and b/assets/images/levels/level16.jpg differ diff --git a/assets/images/levels/level17.jpg b/assets/images/levels/level17.jpg new file mode 100644 index 0000000..f34534d Binary files /dev/null and b/assets/images/levels/level17.jpg differ diff --git a/assets/images/levels/level18.jpg b/assets/images/levels/level18.jpg new file mode 100644 index 0000000..726b839 Binary files /dev/null and b/assets/images/levels/level18.jpg differ diff --git a/assets/images/levels/level19.jpg b/assets/images/levels/level19.jpg new file mode 100644 index 0000000..cb2a64d Binary files /dev/null and b/assets/images/levels/level19.jpg differ diff --git a/assets/images/levels/level2.jpg b/assets/images/levels/level2.jpg new file mode 100644 index 0000000..e98da88 Binary files /dev/null and b/assets/images/levels/level2.jpg differ diff --git a/assets/images/levels/level20.jpg b/assets/images/levels/level20.jpg new file mode 100644 index 0000000..e2cc918 Binary files /dev/null and b/assets/images/levels/level20.jpg differ diff --git a/assets/images/levels/level21.jpg b/assets/images/levels/level21.jpg new file mode 100644 index 0000000..dd03a6b Binary files /dev/null and b/assets/images/levels/level21.jpg differ diff --git a/assets/images/levels/level22.jpg b/assets/images/levels/level22.jpg new file mode 100644 index 0000000..1ec27fe Binary files /dev/null and b/assets/images/levels/level22.jpg differ diff --git a/assets/images/levels/level23.jpg b/assets/images/levels/level23.jpg new file mode 100644 index 0000000..cf7aa39 Binary files /dev/null and b/assets/images/levels/level23.jpg differ diff --git a/assets/images/levels/level24.jpg b/assets/images/levels/level24.jpg new file mode 100644 index 0000000..f457f1e Binary files /dev/null and b/assets/images/levels/level24.jpg differ diff --git a/assets/images/levels/level25.jpg b/assets/images/levels/level25.jpg new file mode 100644 index 0000000..cc83029 Binary files /dev/null and b/assets/images/levels/level25.jpg differ diff --git a/assets/images/levels/level26.jpg b/assets/images/levels/level26.jpg new file mode 100644 index 0000000..daf8d82 Binary files /dev/null and b/assets/images/levels/level26.jpg differ diff --git a/assets/images/levels/level27.jpg b/assets/images/levels/level27.jpg new file mode 100644 index 0000000..335fde2 Binary files /dev/null and b/assets/images/levels/level27.jpg differ diff --git a/assets/images/levels/level28.jpg b/assets/images/levels/level28.jpg new file mode 100644 index 0000000..5808398 Binary files /dev/null and b/assets/images/levels/level28.jpg differ diff --git a/assets/images/levels/level29.jpg b/assets/images/levels/level29.jpg new file mode 100644 index 0000000..e2d167d Binary files /dev/null and b/assets/images/levels/level29.jpg differ diff --git a/assets/images/levels/level3.jpg b/assets/images/levels/level3.jpg new file mode 100644 index 0000000..fb34d25 Binary files /dev/null and b/assets/images/levels/level3.jpg differ diff --git a/assets/images/levels/level30.jpg b/assets/images/levels/level30.jpg new file mode 100644 index 0000000..31451a1 Binary files /dev/null and b/assets/images/levels/level30.jpg differ diff --git a/assets/images/levels/level31.jpg b/assets/images/levels/level31.jpg new file mode 100644 index 0000000..1544ada Binary files /dev/null and b/assets/images/levels/level31.jpg differ diff --git a/assets/images/levels/level32.jpg b/assets/images/levels/level32.jpg new file mode 100644 index 0000000..9765ab6 Binary files /dev/null and b/assets/images/levels/level32.jpg differ diff --git a/assets/images/levels/level4.jpg b/assets/images/levels/level4.jpg new file mode 100644 index 0000000..3a614e4 Binary files /dev/null and b/assets/images/levels/level4.jpg differ diff --git a/assets/images/levels/level5.jpg b/assets/images/levels/level5.jpg new file mode 100644 index 0000000..6b8c544 Binary files /dev/null and b/assets/images/levels/level5.jpg differ diff --git a/assets/images/levels/level6.jpg b/assets/images/levels/level6.jpg new file mode 100644 index 0000000..7882fe9 Binary files /dev/null and b/assets/images/levels/level6.jpg differ diff --git a/assets/images/levels/level7.jpg b/assets/images/levels/level7.jpg new file mode 100644 index 0000000..415b12c Binary files /dev/null and b/assets/images/levels/level7.jpg differ diff --git a/assets/images/levels/level8.jpg b/assets/images/levels/level8.jpg new file mode 100644 index 0000000..584c664 Binary files /dev/null and b/assets/images/levels/level8.jpg differ diff --git a/assets/images/levels/level9.jpg b/assets/images/levels/level9.jpg new file mode 100644 index 0000000..e2c69b4 Binary files /dev/null and b/assets/images/levels/level9.jpg differ diff --git a/assets/images/logo.bmp b/assets/images/logo.bmp deleted file mode 100644 index b7cc6c0..0000000 Binary files a/assets/images/logo.bmp and /dev/null differ diff --git a/assets/images/logo.webp b/assets/images/logo.webp deleted file mode 100644 index de63c6b..0000000 Binary files a/assets/images/logo.webp and /dev/null differ diff --git a/assets/images/logo_small.bmp b/assets/images/logo_small.bmp deleted file mode 100644 index 42557de..0000000 Binary files a/assets/images/logo_small.bmp and /dev/null differ diff --git a/assets/images/logo_small.webp b/assets/images/logo_small.webp deleted file mode 100644 index 18f1133..0000000 Binary files a/assets/images/logo_small.webp and /dev/null differ diff --git a/assets/images/main_screen.png b/assets/images/main_screen.png new file mode 100644 index 0000000..3f593b8 Binary files /dev/null and b/assets/images/main_screen.png differ diff --git a/assets/images/next_panel.png b/assets/images/next_panel.png new file mode 100644 index 0000000..d57947a Binary files /dev/null and b/assets/images/next_panel.png differ diff --git a/assets/images/background.png b/assets/images/old/background.png similarity index 100% rename from assets/images/background.png rename to assets/images/old/background.png diff --git a/assets/images/background.webp b/assets/images/old/background.webp similarity index 100% rename from assets/images/background.webp rename to assets/images/old/background.webp diff --git a/assets/images/main_background.webp b/assets/images/old/main_background.webp similarity index 100% rename from assets/images/main_background.webp rename to assets/images/old/main_background.webp diff --git a/assets/images/tetris_main_back_level0.jpg b/assets/images/old/tetris_main_back_level0.jpg similarity index 100% rename from assets/images/tetris_main_back_level0.jpg rename to assets/images/old/tetris_main_back_level0.jpg diff --git a/assets/images/tetris_main_level_0.webp b/assets/images/old/tetris_main_level_0.webp similarity index 100% rename from assets/images/tetris_main_level_0.webp rename to assets/images/old/tetris_main_level_0.webp diff --git a/assets/images/panel_score.png b/assets/images/panel_score.png new file mode 100644 index 0000000..c0304cc Binary files /dev/null and b/assets/images/panel_score.png differ diff --git a/assets/images/spacetris.png b/assets/images/spacetris.png new file mode 100644 index 0000000..0c7f249 Binary files /dev/null and b/assets/images/spacetris.png differ diff --git a/assets/images/statistics_panel.png b/assets/images/statistics_panel.png new file mode 100644 index 0000000..19a59c1 Binary files /dev/null and b/assets/images/statistics_panel.png differ diff --git a/assets/images/tetris_main_back_level1.jpg b/assets/images/tetris_main_back_level1.jpg deleted file mode 100644 index 1e2e3e7..0000000 Binary files a/assets/images/tetris_main_back_level1.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level10.jpg b/assets/images/tetris_main_back_level10.jpg deleted file mode 100644 index 6a0d8a7..0000000 Binary files a/assets/images/tetris_main_back_level10.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level11.jpg b/assets/images/tetris_main_back_level11.jpg deleted file mode 100644 index 68fbea9..0000000 Binary files a/assets/images/tetris_main_back_level11.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level12.jpg b/assets/images/tetris_main_back_level12.jpg deleted file mode 100644 index 2236820..0000000 Binary files a/assets/images/tetris_main_back_level12.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level13.jpg b/assets/images/tetris_main_back_level13.jpg deleted file mode 100644 index 0f85309..0000000 Binary files a/assets/images/tetris_main_back_level13.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level14.jpg b/assets/images/tetris_main_back_level14.jpg deleted file mode 100644 index fb7b55a..0000000 Binary files a/assets/images/tetris_main_back_level14.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level15.jpg b/assets/images/tetris_main_back_level15.jpg deleted file mode 100644 index fadc33e..0000000 Binary files a/assets/images/tetris_main_back_level15.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level16.jpg b/assets/images/tetris_main_back_level16.jpg deleted file mode 100644 index 0506f1b..0000000 Binary files a/assets/images/tetris_main_back_level16.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level17.jpg b/assets/images/tetris_main_back_level17.jpg deleted file mode 100644 index 238cfa2..0000000 Binary files a/assets/images/tetris_main_back_level17.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level18.jpg b/assets/images/tetris_main_back_level18.jpg deleted file mode 100644 index a347b0a..0000000 Binary files a/assets/images/tetris_main_back_level18.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level19.jpg b/assets/images/tetris_main_back_level19.jpg deleted file mode 100644 index 2159b45..0000000 Binary files a/assets/images/tetris_main_back_level19.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level2.jpg b/assets/images/tetris_main_back_level2.jpg deleted file mode 100644 index 276e092..0000000 Binary files a/assets/images/tetris_main_back_level2.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level20.jpg b/assets/images/tetris_main_back_level20.jpg deleted file mode 100644 index a805fcf..0000000 Binary files a/assets/images/tetris_main_back_level20.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level21.jpg b/assets/images/tetris_main_back_level21.jpg deleted file mode 100644 index b0b3c56..0000000 Binary files a/assets/images/tetris_main_back_level21.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level22.jpg b/assets/images/tetris_main_back_level22.jpg deleted file mode 100644 index fa41309..0000000 Binary files a/assets/images/tetris_main_back_level22.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level23.jpg b/assets/images/tetris_main_back_level23.jpg deleted file mode 100644 index a9f1165..0000000 Binary files a/assets/images/tetris_main_back_level23.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level24.jpg b/assets/images/tetris_main_back_level24.jpg deleted file mode 100644 index c2e1d1d..0000000 Binary files a/assets/images/tetris_main_back_level24.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level25.jpg b/assets/images/tetris_main_back_level25.jpg deleted file mode 100644 index ef6c890..0000000 Binary files a/assets/images/tetris_main_back_level25.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level26.jpg b/assets/images/tetris_main_back_level26.jpg deleted file mode 100644 index 96e9c26..0000000 Binary files a/assets/images/tetris_main_back_level26.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level27.jpg b/assets/images/tetris_main_back_level27.jpg deleted file mode 100644 index effe5a8..0000000 Binary files a/assets/images/tetris_main_back_level27.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level28.jpg b/assets/images/tetris_main_back_level28.jpg deleted file mode 100644 index caa71cd..0000000 Binary files a/assets/images/tetris_main_back_level28.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level29.jpg b/assets/images/tetris_main_back_level29.jpg deleted file mode 100644 index b77e3a5..0000000 Binary files a/assets/images/tetris_main_back_level29.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level3.jpg b/assets/images/tetris_main_back_level3.jpg deleted file mode 100644 index 0ead72f..0000000 Binary files a/assets/images/tetris_main_back_level3.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level30.jpg b/assets/images/tetris_main_back_level30.jpg deleted file mode 100644 index 6503d7e..0000000 Binary files a/assets/images/tetris_main_back_level30.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level31.jpg b/assets/images/tetris_main_back_level31.jpg deleted file mode 100644 index c480b0b..0000000 Binary files a/assets/images/tetris_main_back_level31.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level32.jpg b/assets/images/tetris_main_back_level32.jpg deleted file mode 100644 index d345caa..0000000 Binary files a/assets/images/tetris_main_back_level32.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level4.jpg b/assets/images/tetris_main_back_level4.jpg deleted file mode 100644 index ce36388..0000000 Binary files a/assets/images/tetris_main_back_level4.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level5.jpg b/assets/images/tetris_main_back_level5.jpg deleted file mode 100644 index 37db308..0000000 Binary files a/assets/images/tetris_main_back_level5.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level6.jpg b/assets/images/tetris_main_back_level6.jpg deleted file mode 100644 index 559898d..0000000 Binary files a/assets/images/tetris_main_back_level6.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level7.jpg b/assets/images/tetris_main_back_level7.jpg deleted file mode 100644 index d010cbc..0000000 Binary files a/assets/images/tetris_main_back_level7.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level8.jpg b/assets/images/tetris_main_back_level8.jpg deleted file mode 100644 index 382468a..0000000 Binary files a/assets/images/tetris_main_back_level8.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level9.jpg b/assets/images/tetris_main_back_level9.jpg deleted file mode 100644 index 955c1c5..0000000 Binary files a/assets/images/tetris_main_back_level9.jpg and /dev/null differ diff --git a/src/audio/MenuWrappers.h b/src/audio/MenuWrappers.h index 08a2d66..ad2e98c 100644 --- a/src/audio/MenuWrappers.h +++ b/src/audio/MenuWrappers.h @@ -12,10 +12,7 @@ void menu_updateFireworks(double frameMs); double menu_getLogoAnimCounter(); int menu_getHoveredButton(); -void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, bool isHovered, bool isSelected); - -void menu_drawMenuButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, SDL_Color bgColor, SDL_Color borderColor); - -void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled); +// Legacy wrappers removed +// void menu_drawEnhancedButton(...); +// void menu_drawMenuButton(...); +// void menu_drawSettingsPopup(...); diff --git a/src/core/Config.h b/src/core/Config.h index 563d92b..500c4ca 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -32,7 +32,7 @@ namespace Config { constexpr int MAX_LEVELS = 20; // Maximum selectable starting level // Gravity speed multiplier: 1.0 = normal, 2.0 = 2x slower, 0.5 = 2x faster - constexpr double GRAVITY_SPEED_MULTIPLIER = 1; + constexpr double GRAVITY_SPEED_MULTIPLIER = 2; // increase drop interval by ~100% to slow gravity } // UI Layout constants diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index d6e26d6..f626e04 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -1131,6 +1131,9 @@ void ApplicationManager::setupStateHandlers() { m_stateContext.pixelFont, m_stateContext.lineEffect, m_stateContext.blocksTex, + m_stateContext.statisticsPanelTex, + m_stateContext.scorePanelTex, + m_stateContext.nextPanelTex, LOGICAL_W, LOGICAL_H, logicalScale, diff --git a/src/gameplay/core/Game.cpp b/src/gameplay/core/Game.cpp index ce174db..10cd22d 100644 --- a/src/gameplay/core/Game.cpp +++ b/src/gameplay/core/Game.cpp @@ -2,6 +2,7 @@ #include "Game.h" #include #include +#include #include // Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0). @@ -54,6 +55,10 @@ void Game::reset(int startLevel_) { std::fill(blockCounts.begin(), blockCounts.end(), 0); bag.clear(); _score = 0; _lines = 0; _level = startLevel_; startLevel = startLevel_; + _tetrisesMade = 0; + _currentCombo = 0; + _maxCombo = 0; + _comboCount = 0; // Initialize gravity using NES timing table (ms per cell by level) gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier); fallAcc = 0; gameOver=false; paused=false; @@ -217,6 +222,19 @@ void Game::lockPiece() { // Update total lines _lines += cleared; + // Update combo counters: consecutive clears increase combo; reset when no clear + _currentCombo += 1; + if (_currentCombo > _maxCombo) _maxCombo = _currentCombo; + // Count combos as any single clear that removes more than 1 line + if (cleared > 1) { + _comboCount += 1; + } + + // Track tetrises made + if (cleared == 4) { + _tetrisesMade += 1; + } + // JS level progression (NES-like) using starting level rules // Both startLevel and _level are 0-based now. int targetLevel = startLevel; @@ -241,7 +259,10 @@ void Game::lockPiece() { soundCallback(cleared); } } - + else { + // No clear -> reset combo + _currentCombo = 0; + } if (!gameOver) spawn(); } @@ -400,8 +421,28 @@ void Game::rotate(int dir) { // Try rotation at current position first if (!collides(p)) { - cur = p; - return; + // If rotation at current position would place cells above the top, + // attempt to shift it down so the topmost block sits at gy == 0. + int minGy = std::numeric_limits::max(); + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!cellFilled(p, cx, cy)) continue; + minGy = std::min(minGy, p.y + cy); + } + } + + if (minGy < 0) { + Piece adj = p; + adj.y += -minGy; + if (!collides(adj)) { + cur = adj; + return; + } + // Can't shift into place without collision - fall through to kicks + } else { + cur = p; + return; + } } // Standard SRS Wall Kicks @@ -457,8 +498,30 @@ void Game::rotate(int dir) { test.x = cur.x + kick.first; test.y = cur.y + kick.second; if (!collides(test)) { - cur = test; - return; + // Prevent rotated piece from ending up above the visible playfield. + // If any cell of `test` is above the top (gy < 0), try shifting it + // downward so the highest block sits at row 0. Accept the shift + // only if it doesn't collide; otherwise keep searching kicks. + int minGy = std::numeric_limits::max(); + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!cellFilled(test, cx, cy)) continue; + minGy = std::min(minGy, test.y + cy); + } + } + + if (minGy < 0) { + Piece adj = test; + adj.y += -minGy; // shift down so topmost block is at gy == 0 + if (!collides(adj)) { + cur = adj; + return; + } + // couldn't shift without collision, try next kick + } else { + cur = test; + return; + } } } } diff --git a/src/gameplay/core/Game.h b/src/gameplay/core/Game.h index 63774bc..5f36ffe 100644 --- a/src/gameplay/core/Game.h +++ b/src/gameplay/core/Game.h @@ -81,6 +81,10 @@ public: const std::vector& getHardDropCells() const { return hardDropCells; } uint32_t getHardDropFxId() const { return hardDropFxId; } uint64_t getCurrentPieceSequence() const { return pieceSequence; } + // Additional stats + int tetrisesMade() const { return _tetrisesMade; } + int maxCombo() const { return _maxCombo; } + int comboCount() const { return _comboCount; } private: std::array board{}; // 0 empty else color index @@ -94,6 +98,10 @@ private: int _score{0}; int _lines{0}; int _level{1}; + int _tetrisesMade{0}; + int _currentCombo{0}; + int _maxCombo{0}; + int _comboCount{0}; double gravityMs{800.0}; double fallAcc{0.0}; Uint64 _startTime{0}; // Performance counter at game start diff --git a/src/graphics/GameRenderer.cpp b/src/graphics/GameRenderer.cpp index 8cb7552..4a5f339 100644 --- a/src/graphics/GameRenderer.cpp +++ b/src/graphics/GameRenderer.cpp @@ -122,6 +122,9 @@ void GameRenderer::renderPlayingState( FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* statisticsPanelTex, + SDL_Texture* scorePanelTex, + SDL_Texture* nextPanelTex, float logicalW, float logicalH, float logicalScale, @@ -198,10 +201,19 @@ void GameRenderer::renderPlayingState( lineEffect->startLineClear(completedLines, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize)); } - // Draw game grid border - drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); - drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - 1 - contentOffsetY, GRID_W + 2, GRID_H + 2, {60, 80, 160, 255}); - drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); + // 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); @@ -211,7 +223,7 @@ void GameRenderer::renderPlayingState( SDL_FRect rbg{scoreX - 16, gridY - 16, statsW + 32, GRID_H + 32}; SDL_RenderFillRect(renderer, &rbg); - // Draw grid lines + // Draw grid lines (solid so grid remains legible over background) SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); for (int x = 1; x < Game::COLS; ++x) { float lineX = gridX + x * finalBlockSize; @@ -233,8 +245,16 @@ void GameRenderer::renderPlayingState( drawRectWithOffset(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255}); // Draw next piece preview panel border - 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}); + // If a NEXT panel texture was provided, draw it instead of the custom + // background/outline. The texture will be scaled to fit the panel area. + if (nextPanelTex) { + SDL_FRect dst{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH }; + SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND); + SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst); + } else { + drawRectWithOffset(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255}); + drawRectWithOffset(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255}); + } // Draw the game board const auto &board = game->boardRef(); @@ -297,7 +317,8 @@ void GameRenderer::renderPlayingState( // Draw next piece preview pixelFont->draw(renderer, nextX + 10, nextY - 20, "NEXT", 1.0f, {255, 220, 0, 255}); if (game->next().type < PIECE_COUNT) { - drawSmallPiece(renderer, blocksTex, static_cast(game->next().type), nextX + 10, nextY + 10, finalBlockSize * 0.6f); + // Nudge preview slightly upward to remove thin bottom artifact + drawSmallPiece(renderer, blocksTex, static_cast(game->next().type), nextX + 10, nextY + 5, finalBlockSize * 0.6f); } // Draw block statistics (left panel) @@ -339,14 +360,20 @@ void GameRenderer::renderPlayingState( snprintf(countStr, sizeof(countStr), "%d", count); int countW = 0, countH = 0; pixelFont->measure(countStr, 1.0f, countW, countH); - float countX = previewX + rowWidth - static_cast(countW); + // Horizontal shift to push the counts/percent a bit more to the right + const float statsNumbersShift = 20.0f; + // Small left shift for progress bar so the track aligns better with the design + const float statsBarShift = -10.0f; + float countX = previewX + rowWidth - static_cast(countW) + statsNumbersShift; float countY = previewY + 9.0f; int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(count) / double(totalBlocks))) : 0; char percStr[16]; snprintf(percStr, sizeof(percStr), "%d%%", perc); + int percW = 0, percH = 0; + pixelFont->measure(percStr, 0.8f, percW, percH); - float barX = previewX; + float barX = previewX + statsBarShift; float barY = previewY + pieceHeight + 12.0f; float barH = 6.0f; float barW = rowWidth; @@ -369,7 +396,9 @@ void GameRenderer::renderPlayingState( drawSmallPiece(renderer, blocksTex, static_cast(i), previewX, previewY, previewSize); pixelFont->draw(renderer, countX, countY, countStr, 1.0f, {245, 245, 255, 255}); - pixelFont->draw(renderer, previewX, percY, percStr, 0.8f, {215, 225, 240, 255}); + // Draw percent right-aligned near the same right edge as the count + float percX = previewX + rowWidth - static_cast(percW) + statsNumbersShift; + pixelFont->draw(renderer, percX, percY, percStr, 0.8f, {215, 225, 240, 255}); SDL_SetRenderDrawColor(renderer, 110, 120, 140, 200); SDL_FRect track{barX, barY, barW, barH}; diff --git a/src/graphics/GameRenderer.h b/src/graphics/GameRenderer.h index 4e7767e..5b074da 100644 --- a/src/graphics/GameRenderer.h +++ b/src/graphics/GameRenderer.h @@ -20,8 +20,11 @@ public: Game* game, FontAtlas* pixelFont, LineEffect* lineEffect, - SDL_Texture* blocksTex, - float logicalW, + SDL_Texture* blocksTex, + SDL_Texture* statisticsPanelTex, + SDL_Texture* scorePanelTex, + SDL_Texture* nextPanelTex, + float logicalW, float logicalH, float logicalScale, float winW, diff --git a/src/graphics/effects/SpaceWarp.cpp b/src/graphics/effects/SpaceWarp.cpp new file mode 100644 index 0000000..89d03f6 --- /dev/null +++ b/src/graphics/effects/SpaceWarp.cpp @@ -0,0 +1,365 @@ +#include "SpaceWarp.h" + +#include +#include +#include + +namespace { +constexpr float MIN_ASPECT = 0.001f; +} + +SpaceWarp::SpaceWarp() { + std::random_device rd; + rng.seed(rd()); + setFlightMode(SpaceWarpFlightMode::Forward); +} + +void SpaceWarp::init(int w, int h, int starCount) { + resize(w, h); + stars.resize(std::max(8, starCount)); + for (auto& star : stars) { + respawn(star, true); + } + comets.clear(); + cometSpawnTimer = randomRange(settings.cometSpawnIntervalMin, settings.cometSpawnIntervalMax); +} + +void SpaceWarp::resize(int w, int h) { + width = std::max(1, w); + height = std::max(1, h); + centerX = width * 0.5f; + centerY = height * 0.5f; + warpFactor = std::max(width, height) * settings.warpFactorScale; +} + +void SpaceWarp::setSettings(const SpaceWarpSettings& newSettings) { + settings = newSettings; + warpFactor = std::max(width, height) * settings.warpFactorScale; + cometSpawnTimer = std::clamp(cometSpawnTimer, settings.cometSpawnIntervalMin, settings.cometSpawnIntervalMax); +} + +void SpaceWarp::setFlightMode(SpaceWarpFlightMode mode) { + flightMode = mode; + autoPilotEnabled = false; + switch (mode) { + case SpaceWarpFlightMode::Forward: + motion = {1.0f, 0.0f, 0.0f}; + break; + case SpaceWarpFlightMode::BankLeft: + motion = {1.05f, -0.85f, 0.0f}; + break; + case SpaceWarpFlightMode::BankRight: + motion = {1.05f, 0.85f, 0.0f}; + break; + case SpaceWarpFlightMode::Reverse: + motion = {-0.6f, 0.0f, 0.0f}; + break; + case SpaceWarpFlightMode::Custom: + default: + break; + } +} + +void SpaceWarp::setFlightMotion(const SpaceWarpFlightMotion& newMotion) { + motion = newMotion; + flightMode = SpaceWarpFlightMode::Custom; + autoPilotEnabled = false; +} + +void SpaceWarp::setAutoPilotEnabled(bool enabled) { + autoPilotEnabled = enabled; + if (enabled) { + flightMode = SpaceWarpFlightMode::Custom; + motionTarget = motion; + autoTimer = 0.0f; + scheduleNewAutoTarget(); + } +} + +void SpaceWarp::scheduleNewAutoTarget() { + // Autopilot behavior: + // - 90% of the time: gentle forward flight with small lateral/vertical drift + // - 10% of the time: short lateral "bank" burst (stronger lateral speed) for a while + float choice = randomRange(0.0f, 1.0f); + if (choice < 0.90f) { + // Normal forward flight + motionTarget.forwardScale = randomRange(0.95f, 1.12f); + motionTarget.lateralSpeed = randomRange(-0.18f, 0.18f); + motionTarget.verticalSpeed = randomRange(-0.12f, 0.12f); + // Longer interval between aggressive maneuvers + autoTimer = randomRange(autoMinInterval, autoMaxInterval); + } else { + // Occasional lateral bank burst + motionTarget.forwardScale = randomRange(0.90f, 1.10f); + // Pick left or right burst + float dir = (randomRange(0.0f, 1.0f) < 0.5f) ? -1.0f : 1.0f; + motionTarget.lateralSpeed = dir * randomRange(0.70f, 1.35f); + // Allow modest vertical bias during a bank + motionTarget.verticalSpeed = randomRange(-0.35f, 0.35f); + // Shorter duration for the burst so it feels like a brief maneuver + autoTimer = randomRange(1.0f, 3.0f); + } +} + +void SpaceWarp::spawnComet() { + WarpComet comet; + float aspect = static_cast(width) / static_cast(std::max(1, height)); + float normalizedAspect = std::max(aspect, MIN_ASPECT); + float xRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? aspect : 1.0f); + float yRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect)); + comet.x = randomRange(-xRange, xRange); + comet.y = randomRange(-yRange, yRange); + comet.z = randomRange(minDepth + 4.0f, maxDepth); + float baseSpeed = randomRange(settings.minSpeed, settings.maxSpeed); + float multiplier = randomRange(settings.cometSpeedMultiplierMin, settings.cometSpeedMultiplierMax); + comet.speed = baseSpeed * multiplier; + comet.size = randomRange(settings.cometMinSize, settings.cometMaxSize); + comet.trailLength = randomRange(settings.cometMinTrail, settings.cometMaxTrail); + comet.life = randomRange(1.8f, 3.4f); + comet.maxLife = comet.life; + float shade = randomRange(0.85f, 1.0f); + Uint8 c = static_cast(std::clamp(220.0f + shade * 35.0f, 0.0f, 255.0f)); + comet.color = SDL_Color{c, Uint8(std::min(255.0f, c * 0.95f)), 255, 255}; + // Initialize screen positions based on projection so the comet is not stuck at center + float sx = 0.0f, sy = 0.0f; + if (projectPoint(comet.x, comet.y, comet.z, sx, sy)) { + comet.screenX = sx; + comet.screenY = sy; + // Place prev slightly behind the head so the first frame shows motion/trail + float jitter = std::max(4.0f, comet.trailLength * 0.08f); + float ang = randomRange(0.0f, 6.28318530718f); + comet.prevScreenX = comet.screenX - std::cos(ang) * jitter; + comet.prevScreenY = comet.screenY - std::sin(ang) * jitter; + } else { + comet.prevScreenX = centerX; + comet.prevScreenY = centerY; + comet.screenX = centerX; + comet.screenY = centerY; + } + comets.push_back(comet); +} + +float SpaceWarp::randomRange(float min, float max) { + std::uniform_real_distribution dist(min, max); + return dist(rng); +} + +static int randomIntInclusive(std::mt19937& rng, int min, int max) { + std::uniform_int_distribution dist(min, max); + return dist(rng); +} + +void SpaceWarp::respawn(WarpStar& star, bool randomDepth) { + float aspect = static_cast(width) / static_cast(std::max(1, height)); + float normalizedAspect = std::max(aspect, MIN_ASPECT); + float xRange = settings.baseSpawnRange * (aspect >= 1.0f ? aspect : 1.0f); + float yRange = settings.baseSpawnRange * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect)); + star.x = randomRange(-xRange, xRange); + star.y = randomRange(-yRange, yRange); + star.z = randomDepth ? randomRange(minDepth, maxDepth) : maxDepth; + star.speed = randomRange(settings.minSpeed, settings.maxSpeed); + star.shade = randomRange(settings.minShade, settings.maxShade); + static constexpr Uint8 GRAY_SHADES[] = {160, 180, 200, 220, 240}; + int idx = randomIntInclusive(rng, 0, int(std::size(GRAY_SHADES)) - 1); + star.baseShade = GRAY_SHADES[idx]; + // Compute initial projected screen position so newly spawned stars aren't frozen at center + float sx = 0.0f, sy = 0.0f; + if (projectPoint(star.x, star.y, star.z, sx, sy)) { + star.screenX = sx; + star.screenY = sy; + // give a small previous offset so trails and motion are visible immediately + float jitter = std::max(1.0f, settings.maxTrailLength * 0.06f); + float ang = randomRange(0.0f, 6.28318530718f); + star.prevScreenX = star.screenX - std::cos(ang) * jitter; + star.prevScreenY = star.screenY - std::sin(ang) * jitter; + } else { + star.prevScreenX = centerX; + star.prevScreenY = centerY; + star.screenX = centerX; + star.screenY = centerY; + } +} + +bool SpaceWarp::project(const WarpStar& star, float& outX, float& outY) const { + return projectPoint(star.x, star.y, star.z, outX, outY); +} + +bool SpaceWarp::projectPoint(float x, float y, float z, float& outX, float& outY) const { + if (z <= minDepth) { + return false; + } + float perspective = warpFactor / (z + 0.001f); + outX = centerX + x * perspective; + outY = centerY + y * perspective; + const float margin = settings.spawnMargin; + return outX >= -margin && outX <= width + margin && outY >= -margin && outY <= height + margin; +} + +void SpaceWarp::update(float deltaSeconds) { + if (stars.empty()) { + return; + } + + if (settings.cometSpawnIntervalMax > 0.0f) { + cometSpawnTimer -= deltaSeconds; + if (cometSpawnTimer <= 0.0f) { + spawnComet(); + cometSpawnTimer = randomRange(settings.cometSpawnIntervalMin, settings.cometSpawnIntervalMax); + } + } + + if (autoPilotEnabled) { + autoTimer -= deltaSeconds; + if (autoTimer <= 0.0f) { + scheduleNewAutoTarget(); + } + auto follow = std::clamp(deltaSeconds * 0.45f, 0.0f, 1.0f); + motion.forwardScale = std::lerp(motion.forwardScale, motionTarget.forwardScale, follow); + motion.lateralSpeed = std::lerp(motion.lateralSpeed, motionTarget.lateralSpeed, follow); + motion.verticalSpeed = std::lerp(motion.verticalSpeed, motionTarget.verticalSpeed, follow); + } + + const float forwardScale = (std::abs(motion.forwardScale) < 0.01f) + ? (motion.forwardScale >= 0.0f ? 0.01f : -0.01f) + : motion.forwardScale; + const bool movingBackward = forwardScale < 0.0f; + const float lateralSpeed = motion.lateralSpeed; + const float verticalSpeed = motion.verticalSpeed; + + for (auto& star : stars) { + star.z -= star.speed * deltaSeconds * forwardScale; + if (!movingBackward) { + if (star.z <= minDepth) { + respawn(star, true); + continue; + } + } else { + if (star.z >= maxDepth) { + respawn(star, true); + star.z = minDepth + randomRange(0.25f, 24.0f); + continue; + } + } + + float closeness = 1.0f - std::clamp(star.z / maxDepth, 0.0f, 1.0f); + float driftScale = (0.35f + closeness * 1.25f); + star.x += lateralSpeed * deltaSeconds * driftScale; + star.y += verticalSpeed * deltaSeconds * driftScale; + + float sx = 0.0f; + float sy = 0.0f; + if (!project(star, sx, sy)) { + respawn(star, true); + continue; + } + + star.prevScreenX = star.screenX; + star.prevScreenY = star.screenY; + star.screenX = sx; + star.screenY = sy; + + float dx = star.screenX - star.prevScreenX; + float dy = star.screenY - star.prevScreenY; + float lenSq = dx * dx + dy * dy; + float maxStreak = std::max(settings.maxTrailLength, 0.0f); + if (maxStreak > 0.0f && lenSq > maxStreak * maxStreak) { + float len = std::sqrt(lenSq); + float scale = maxStreak / len; + star.prevScreenX = star.screenX - dx * scale; + star.prevScreenY = star.screenY - dy * scale; + } + } + + for (auto it = comets.begin(); it != comets.end();) { + auto& comet = *it; + comet.life -= deltaSeconds; + comet.z -= comet.speed * deltaSeconds * forwardScale; + bool expired = comet.life <= 0.0f; + if (!movingBackward) { + if (comet.z <= minDepth * 0.35f) expired = true; + } else { + if (comet.z >= maxDepth + 40.0f) expired = true; + } + + float closeness = 1.0f - std::clamp(comet.z / maxDepth, 0.0f, 1.0f); + float driftScale = (0.45f + closeness * 1.6f); + comet.x += lateralSpeed * deltaSeconds * driftScale; + comet.y += verticalSpeed * deltaSeconds * driftScale; + + float sx = 0.0f; + float sy = 0.0f; + if (!projectPoint(comet.x, comet.y, comet.z, sx, sy)) { + expired = true; + } else { + comet.prevScreenX = comet.screenX; + comet.prevScreenY = comet.screenY; + comet.screenX = sx; + comet.screenY = sy; + + float dx = comet.screenX - comet.prevScreenX; + float dy = comet.screenY - comet.prevScreenY; + float lenSq = dx * dx + dy * dy; + float maxTrail = std::max(comet.trailLength, 0.0f); + if (maxTrail > 0.0f && lenSq > maxTrail * maxTrail) { + float len = std::sqrt(lenSq); + float scale = maxTrail / len; + comet.prevScreenX = comet.screenX - dx * scale; + comet.prevScreenY = comet.screenY - dy * scale; + } + } + + if (expired) { + it = comets.erase(it); + } else { + ++it; + } + } +} + +void SpaceWarp::draw(SDL_Renderer* renderer, float alphaScale) { + if (stars.empty()) { + return; + } + + SDL_BlendMode previous = SDL_BLENDMODE_NONE; + SDL_GetRenderDrawBlendMode(renderer, &previous); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); + + for (const auto& star : stars) { + float depthFactor = 1.0f - std::clamp(star.z / maxDepth, 0.0f, 1.0f); + float alphaBase = std::clamp(settings.minAlpha + depthFactor * settings.alphaDepthBoost, 0.0f, 255.0f); + Uint8 alpha = static_cast(std::clamp(alphaBase * alphaScale, 0.0f, 255.0f)); + float colorValue = std::clamp( + star.baseShade * (settings.baseShadeScale + depthFactor * settings.depthColorScale) * star.shade, + settings.minColor, + settings.maxColor); + Uint8 color = static_cast(colorValue); + + if (settings.drawTrails) { + float trailAlphaFloat = alpha * settings.trailAlphaScale; + Uint8 trailAlpha = static_cast(std::clamp(trailAlphaFloat, 0.0f, 255.0f)); + SDL_SetRenderDrawColor(renderer, color, color, color, trailAlpha); + SDL_RenderLine(renderer, star.prevScreenX, star.prevScreenY, star.screenX, star.screenY); + } + + float dotSize = std::clamp(settings.minDotSize + depthFactor * (settings.maxDotSize - settings.minDotSize), + settings.minDotSize, + settings.maxDotSize); + SDL_FRect dot{star.screenX - dotSize * 0.5f, star.screenY - dotSize * 0.5f, dotSize, dotSize}; + SDL_SetRenderDrawColor(renderer, color, color, color, alpha); + SDL_RenderFillRect(renderer, &dot); + } + + for (const auto& comet : comets) { + float lifeNorm = std::clamp(comet.life / comet.maxLife, 0.0f, 1.0f); + Uint8 alpha = static_cast(std::clamp(220.0f * lifeNorm, 0.0f, 255.0f)); + SDL_SetRenderDrawColor(renderer, comet.color.r, comet.color.g, comet.color.b, alpha); + SDL_RenderLine(renderer, comet.prevScreenX, comet.prevScreenY, comet.screenX, comet.screenY); + + float size = comet.size * (0.8f + (1.0f - lifeNorm) * 0.6f); + SDL_FRect head{comet.screenX - size * 0.5f, comet.screenY - size * 0.5f, size, size}; + SDL_RenderFillRect(renderer, &head); + } + + SDL_SetRenderDrawBlendMode(renderer, previous); +} diff --git a/src/graphics/effects/SpaceWarp.h b/src/graphics/effects/SpaceWarp.h new file mode 100644 index 0000000..525fdba --- /dev/null +++ b/src/graphics/effects/SpaceWarp.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include + +struct SpaceWarpSettings { + float baseSpawnRange = 1.45f; // logical radius for initial star positions + float warpFactorScale = 122.85f; // scales perspective factor so stars spread faster or slower + float spawnMargin = 60.0f; // how far offscreen a star can travel before respawn + float minShade = 0.85f; // lower bound for per-star brightness multiplier + float maxShade = 1.15f; // upper bound for per-star brightness multiplier + float minSpeed = 120.0f; // slowest warp velocity (higher feels faster motion) + float maxSpeed = 280.0f; // fastest warp velocity + float minDotSize = 2.5f; // smallest star size in pixels + float maxDotSize = 4.5f; // largest star size in pixels + float minAlpha = 70.0f; // base opacity even for distant stars + float alphaDepthBoost = 160.0f; // extra opacity applied as stars approach the camera + float minColor = 180.0f; // clamp for minimum grayscale value + float maxColor = 205.0f; // clamp for maximum grayscale value + float baseShadeScale = 0.75f; // baseline multiplier applied to the sampled grayscale shade + float depthColorScale = 0.55f; // how much depth affects the grayscale brightness + bool drawTrails = true; // when true, also render streak lines for hyper-speed look + float trailAlphaScale = 0.75f; // relative opacity for streak lines vs dots + float maxTrailLength = 36.0f; // clamp length of each streak in pixels + float cometSpawnIntervalMin = 2.8f; // minimum seconds between comet spawns + float cometSpawnIntervalMax = 6.5f; // maximum seconds between comet spawns + float cometSpeedMultiplierMin = 2.2f;// min multiplier for comet forward velocity + float cometSpeedMultiplierMax = 4.5f;// max multiplier for comet forward velocity + float cometMinTrail = 140.0f; // minimum comet trail length in pixels + float cometMaxTrail = 280.0f; // maximum comet trail length in pixels + float cometMinSize = 3.5f; // minimum comet head size + float cometMaxSize = 6.5f; // maximum comet head size +}; + +struct SpaceWarpFlightMotion { + float forwardScale = 1.0f; // multiplier applied to each star's forward velocity (negative = backwards) + float lateralSpeed = 0.0f; // normalized horizontal drift speed (left/right) + float verticalSpeed = 0.0f; // normalized vertical drift speed (up/down) +}; + +enum class SpaceWarpFlightMode { + Forward = 0, + BankLeft, + BankRight, + Reverse, + Custom +}; + +class SpaceWarp { +public: + SpaceWarp(); + void init(int width, int height, int starCount = 320); + void resize(int width, int height); + void update(float deltaSeconds); + void draw(SDL_Renderer* renderer, float alphaScale = 1.0f); + void setSettings(const SpaceWarpSettings& newSettings); + const SpaceWarpSettings& getSettings() const { return settings; } + void setFlightMode(SpaceWarpFlightMode mode); + SpaceWarpFlightMode getFlightMode() const { return flightMode; } + void setFlightMotion(const SpaceWarpFlightMotion& motion); // overrides mode with Custom + const SpaceWarpFlightMotion& getFlightMotion() const { return motion; } + void setAutoPilotEnabled(bool enabled); + bool isAutoPilotEnabled() const { return autoPilotEnabled; } + +private: + struct WarpStar { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float speed = 0.0f; + float prevScreenX = 0.0f; + float prevScreenY = 0.0f; + float screenX = 0.0f; + float screenY = 0.0f; + float shade = 1.0f; + Uint8 baseShade = 220; + }; + + struct WarpComet { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float speed = 0.0f; + float life = 0.0f; + float maxLife = 0.0f; + float prevScreenX = 0.0f; + float prevScreenY = 0.0f; + float screenX = 0.0f; + float screenY = 0.0f; + float trailLength = 160.0f; + float size = 4.0f; + SDL_Color color{255, 255, 255, 255}; + }; + + void respawn(WarpStar& star, bool randomDepth = true); + bool project(const WarpStar& star, float& outX, float& outY) const; + bool projectPoint(float x, float y, float z, float& outX, float& outY) const; + float randomRange(float min, float max); + void spawnComet(); + + std::vector stars; + std::vector comets; + std::mt19937 rng; + + int width = 0; + int height = 0; + float centerX = 0.0f; + float centerY = 0.0f; + float warpFactor = 520.0f; + + SpaceWarpSettings settings{}; + SpaceWarpFlightMotion motion{}; + SpaceWarpFlightMode flightMode = SpaceWarpFlightMode::Forward; + bool autoPilotEnabled = false; + float autoTimer = 0.0f; + float autoMinInterval = 3.5f; + float autoMaxInterval = 7.5f; + SpaceWarpFlightMotion motionTarget{}; + float cometSpawnTimer = 0.0f; + + float minDepth = 2.0f; + float maxDepth = 320.0f; + + void scheduleNewAutoTarget(); +}; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index 8ef3cb1..88222eb 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include "../../core/Settings.h" @@ -24,6 +25,18 @@ struct ImpactSpark { SDL_Color color{255, 255, 255, 255}; }; +struct Sparkle { + float x = 0.0f; + float y = 0.0f; + float vx = 0.0f; + float vy = 0.0f; + float lifeMs = 0.0f; + float maxLifeMs = 0.0f; + float size = 0.0f; + SDL_Color color{255, 255, 255, 255}; + float pulse = 0.0f; +}; + struct ActivePieceSmoothState { uint64_t sequence = 0; float visualX = 0.0f; @@ -34,6 +47,221 @@ ActivePieceSmoothState s_activePieceSmooth; Starfield3D s_inGridStarfield; bool s_starfieldInitialized = false; +std::vector s_sparkles; +float s_sparkleSpawnAcc = 0.0f; +} + +struct TransportEffectState { + bool active = false; + Uint32 startTick = 0; + float durationMs = 600.0f; + Game::Piece piece; + float startX = 0.0f; // pixel origin of piece local (0,0) + float startY = 0.0f; + float targetX = 0.0f; + float targetY = 0.0f; + float tileSize = 24.0f; + // Next preview that should fade in after the transfer completes + Game::Piece nextPiece; + float nextPreviewX = 0.0f; + float nextPreviewY = 0.0f; +}; + +static TransportEffectState s_transport; + +static float smoothstep(float t) { + t = std::clamp(t, 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +void GameRenderer::startTransportEffect(const Game::Piece& piece, float startX, float startY, float targetX, float targetY, float tileSize, float durationSeconds) { + s_transport.active = true; + s_transport.startTick = SDL_GetTicks(); + s_transport.durationMs = std::max(8.0f, durationSeconds * 1000.0f); + s_transport.piece = piece; + s_transport.startX = startX; + s_transport.startY = startY; + s_transport.targetX = targetX; + s_transport.targetY = targetY; + s_transport.tileSize = tileSize; +} + +void GameRenderer::startTransportEffectForGame(Game* game, SDL_Texture* blocksTex, float logicalW, float logicalH, float logicalScale, float winW, float winH, float durationSeconds) { + if (!game) return; + + // Recompute layout exactly like renderPlayingState so coordinates match + const float MIN_MARGIN = 40.0f; + const float TOP_MARGIN = 60.0f; + const float PANEL_WIDTH = 180.0f; + const float PANEL_SPACING = 30.0f; + const float NEXT_PIECE_HEIGHT = 120.0f; + const float BOTTOM_MARGIN = 60.0f; + + float contentScale = logicalScale; + float contentW = logicalW * contentScale; + float contentH = logicalH * contentScale; + float contentOffsetX = (winW - contentW) * 0.5f / contentScale; + float contentOffsetY = (winH - contentH) * 0.5f / contentScale; + + const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2); + const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT; + const float maxBlockSizeW = availableWidth / Game::COLS; + const float maxBlockSizeH = availableHeight / Game::ROWS; + const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH); + 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; + 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; + const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX; + const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY; + + // Compute next panel placement (same as renderPlayingState) + const float NEXT_PANEL_WIDTH = GRID_W - finalBlockSize * 2.0f; + const float NEXT_PANEL_HEIGHT = finalBlockSize * 3.0f; + const float NEXT_PANEL_X = gridX + finalBlockSize; + // Move NEXT panel a bit higher so it visually separates from the grid + const float NEXT_PANEL_Y = gridY - NEXT_PANEL_HEIGHT - 12.0f; + + // We'll animate the piece that is now current (the newly spawned piece) + const Game::Piece piece = game->current(); + + // Determine piece bounds in its 4x4 to center into preview area + int minCx = 4, maxCx = -1, minCy = 4, maxCy = -1; + for (int cy = 0; cy < 4; ++cy) for (int cx = 0; cx < 4; ++cx) if (Game::cellFilled(piece, cx, cy)) { minCx = std::min(minCx, cx); maxCx = std::max(maxCx, cx); minCy = std::min(minCy, cy); maxCy = std::max(maxCy, cy); } + if (maxCx < minCx) { minCx = 0; maxCx = 0; } + if (maxCy < minCy) { minCy = 0; maxCy = 0; } + + const float labelReserve = finalBlockSize * 0.9f; + const float previewTop = NEXT_PANEL_Y + std::min(labelReserve, NEXT_PANEL_HEIGHT * 0.45f); + const float previewBottom = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT - finalBlockSize * 0.25f; + const float previewCenterY = (previewTop + previewBottom) * 0.5f; + const float previewCenterX = std::round(NEXT_PANEL_X + NEXT_PANEL_WIDTH * 0.5f); + + const float pieceWidth = static_cast(maxCx - minCx + 1) * finalBlockSize; + const float pieceHeight = static_cast(maxCy - minCy + 1) * finalBlockSize; + float startX = previewCenterX - pieceWidth * 0.5f - static_cast(minCx) * finalBlockSize; + float startY = previewCenterY - pieceHeight * 0.5f - static_cast(minCy) * finalBlockSize; + // Snap to grid columns + float gridOriginX = NEXT_PANEL_X - finalBlockSize; + float rel = startX - gridOriginX; + float nearestTile = std::round(rel / finalBlockSize); + startX = gridOriginX + nearestTile * finalBlockSize; + // Raise preview slightly to remove a thin line artifact under the panel + startY = std::round(startY) - 5.0f; + + // Target is the current piece's grid position + float targetX = gridX + piece.x * finalBlockSize; + float targetY = gridY + piece.y * finalBlockSize; + + // Also compute where the new NEXT preview (game->next()) will be drawn so we can fade it in later + const Game::Piece nextPiece = game->next(); + + // Compute next preview placement (center within NEXT panel) + int nMinCx = 4, nMaxCx = -1, nMinCy = 4, nMaxCy = -1; + for (int cy = 0; cy < 4; ++cy) for (int cx = 0; cx < 4; ++cx) if (Game::cellFilled(nextPiece, cx, cy)) { nMinCx = std::min(nMinCx, cx); nMaxCx = std::max(nMaxCx, cx); nMinCy = std::min(nMinCy, cy); nMaxCy = std::max(nMaxCy, cy); } + if (nMaxCx < nMinCx) { nMinCx = 0; nMaxCx = 0; } + if (nMaxCy < nMinCy) { nMinCy = 0; nMaxCy = 0; } + + const float previewTop2 = NEXT_PANEL_Y + std::min(finalBlockSize * 0.9f, NEXT_PANEL_HEIGHT * 0.45f); + const float previewBottom2 = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT - finalBlockSize * 0.25f; + const float previewCenterY2 = (previewTop2 + previewBottom2) * 0.5f; + const float previewCenterX2 = std::round(NEXT_PANEL_X + NEXT_PANEL_WIDTH * 0.5f); + const float pieceWidth2 = static_cast(nMaxCx - nMinCx + 1) * finalBlockSize; + const float pieceHeight2 = static_cast(nMaxCy - nMinCy + 1) * finalBlockSize; + float nextPreviewX = previewCenterX2 - pieceWidth2 * 0.5f - static_cast(nMinCx) * finalBlockSize; + float nextPreviewY = previewCenterY2 - pieceHeight2 * 0.5f - static_cast(nMinCy) * finalBlockSize; + // Snap to grid columns + float gridOriginX2 = NEXT_PANEL_X - finalBlockSize; + float rel2 = nextPreviewX - gridOriginX2; + float nearestTile2 = std::round(rel2 / finalBlockSize); + nextPreviewX = gridOriginX2 + nearestTile2 * finalBlockSize; + nextPreviewY = std::round(nextPreviewY) - 5.0f; + + // Initialize transport state to perform fades: preview fade-out -> grid fade-in -> next preview fade-in + s_transport.active = true; + s_transport.startTick = SDL_GetTicks(); + s_transport.durationMs = std::max(100.0f, durationSeconds * 1000.0f); + s_transport.piece = piece; + s_transport.startX = startX; + s_transport.startY = startY; + s_transport.targetX = targetX; + s_transport.targetY = targetY; + s_transport.tileSize = finalBlockSize; + // Store next preview piece and its pixel origin so we can fade it in later + s_transport.nextPiece = nextPiece; + s_transport.nextPreviewX = nextPreviewX; + s_transport.nextPreviewY = nextPreviewY; +} + +bool GameRenderer::isTransportActive() { + return s_transport.active; +} + +// Draw the ongoing transport effect; called every frame from renderPlayingState +static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTex) { + if (!s_transport.active) return; + Uint32 now = SDL_GetTicks(); + float elapsed = static_cast(now - s_transport.startTick); + float total = s_transport.durationMs; + if (total <= 0.0f) total = 1.0f; + // Simultaneous cross-fade: as the NEXT preview fades out, the piece fades into the grid + // and the new NEXT preview fades in โ€” all driven by the same normalized t in [0,1]. + float t = std::clamp(elapsed / total, 0.0f, 1.0f); + Uint8 previewAlpha = static_cast(std::lround(255.0f * (1.0f - t))); + Uint8 gridAlpha = static_cast(std::lround(255.0f * t)); + Uint8 nextAlpha = gridAlpha; // fade new NEXT preview in at same rate as grid + + // Draw preview fade-out + if (previewAlpha > 0) { + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, previewAlpha); + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!Game::cellFilled(s_transport.piece, cx, cy)) continue; + float px = s_transport.startX + static_cast(cx) * s_transport.tileSize; + float py = s_transport.startY + static_cast(cy) * s_transport.tileSize; + GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type); + } + } + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); + } + + // Draw grid fade-in (same intensity as next preview fade-in) + if (gridAlpha > 0) { + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, gridAlpha); + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!Game::cellFilled(s_transport.piece, cx, cy)) continue; + float gx = s_transport.targetX + static_cast(cx) * s_transport.tileSize; + float gy = s_transport.targetY + static_cast(cy) * s_transport.tileSize; + GameRenderer::drawBlockTexturePublic(renderer, blocksTex, gx, gy, s_transport.tileSize, s_transport.piece.type); + } + } + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); + } + + // Draw new NEXT preview fade-in (simultaneous) + if (nextAlpha > 0) { + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, nextAlpha); + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!Game::cellFilled(s_transport.nextPiece, cx, cy)) continue; + float nx = s_transport.nextPreviewX + static_cast(cx) * s_transport.tileSize; + float ny = s_transport.nextPreviewY + static_cast(cy) * s_transport.tileSize; + GameRenderer::drawBlockTexturePublic(renderer, blocksTex, nx, ny, s_transport.tileSize, s_transport.nextPiece.type); + } + } + if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); + } + + if (t >= 1.0f) { + s_transport.active = false; + } } // Color constants (copied from main.cpp) @@ -104,6 +332,11 @@ void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, con } } +void GameRenderer::drawBlockTexturePublic(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) { + // Forward to the private helper + drawBlockTexture(renderer, blocksTex, x, y, size, blockType); +} + void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) { if (pieceType >= PIECE_COUNT) return; @@ -114,10 +347,29 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex previewPiece.x = 0; previewPiece.y = 0; - // Center the piece in the preview area - float offsetX = 0, offsetY = 0; - if (pieceType == 0) { offsetX = tileSize * 0.5f; } // I-piece centering (assuming I = 0) - else if (pieceType == 1) { offsetX = tileSize * 0.5f; } // O-piece centering (assuming O = 1) + // Determine occupied bounds within 4x4 and center inside the 4x4 preview area + int minCx = 4, maxCx = -1, minCy = 4, maxCy = -1; + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (Game::cellFilled(previewPiece, cx, cy)) { + minCx = std::min(minCx, cx); + maxCx = std::max(maxCx, cx); + minCy = std::min(minCy, cy); + maxCy = std::max(maxCy, cy); + } + } + } + if (maxCx < minCx) { minCx = 0; maxCx = 0; } + if (maxCy < minCy) { minCy = 0; maxCy = 0; } + + float areaW = 4.0f * tileSize; + float areaH = 4.0f * tileSize; + float pieceW = static_cast(maxCx - minCx + 1) * tileSize; + float pieceH = static_cast(maxCy - minCy + 1) * tileSize; + float offsetX = (areaW - pieceW) * 0.5f - static_cast(minCx) * tileSize; + float offsetY = (areaH - pieceH) * 0.5f - static_cast(minCy) * tileSize; + offsetX = std::round(offsetX); + offsetY = std::round(offsetY); // Use semi-transparent alpha for preview blocks Uint8 previewAlpha = 180; @@ -130,7 +382,7 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex if (Game::cellFilled(previewPiece, cx, cy)) { float px = x + offsetX + cx * tileSize; float py = y + offsetY + cy * tileSize; - drawBlockTexture(renderer, blocksTex, px, py, tileSize, pieceType); + GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, tileSize, pieceType); } } } @@ -141,12 +393,131 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex } } +void GameRenderer::renderNextPanel( + SDL_Renderer* renderer, + FontAtlas* pixelFont, + SDL_Texture* blocksTex, + SDL_Texture* nextPanelTex, + const Game::Piece& nextPiece, + float panelX, + float panelY, + float panelW, + float panelH, + float tileSize +) { + if (!renderer || !pixelFont) { + return; + } + + const SDL_Color gridBorderColor{60, 80, 160, 255}; // matches main grid outline + const SDL_Color bayColor{8, 12, 24, 235}; + const SDL_Color bayOutline{25, 62, 86, 220}; + const SDL_Color labelColor{255, 220, 0, 255}; + + // If an external NEXT panel texture is provided, draw it scaled into + // the panel rectangle and skip the custom background/frame drawing. + if (nextPanelTex) { + SDL_FRect dst{panelX, panelY, panelW, panelH}; + SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst); + // Draw the panel label over the texture โ€” user requested visible label + const float labelPad = tileSize * 0.25f; + pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor); + } else { + SDL_FRect bayRect{panelX, panelY, panelW, panelH}; + SDL_SetRenderDrawColor(renderer, bayColor.r, bayColor.g, bayColor.b, bayColor.a); + SDL_RenderFillRect(renderer, &bayRect); + + SDL_FRect thinOutline{panelX - 1.0f, panelY - 1.0f, panelW + 2.0f, panelH + 2.0f}; + auto drawOutlineNoBottom = [&](const SDL_FRect& rect, SDL_Color color) { + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + const float left = rect.x; + const float top = rect.y; + const float right = rect.x + rect.w; + const float bottom = rect.y + rect.h; + SDL_RenderLine(renderer, left, top, right, top); // top edge + SDL_RenderLine(renderer, left, top, left, bottom); // left edge + SDL_RenderLine(renderer, right, top, right, bottom); // right edge + }; + + drawOutlineNoBottom(thinOutline, gridBorderColor); + drawOutlineNoBottom(bayRect, bayOutline); + + const float labelPad = tileSize * 0.25f; + pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor); + } + + if (nextPiece.type >= PIECE_COUNT) { + return; + } + + // Determine the occupied bounds of the tetromino within its 4x4 local grid. + int minCx = 4; + int maxCx = -1; + int minCy = 4; + int maxCy = -1; + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!Game::cellFilled(nextPiece, cx, cy)) { + continue; + } + minCx = std::min(minCx, cx); + maxCx = std::max(maxCx, cx); + minCy = std::min(minCy, cy); + maxCy = std::max(maxCy, cy); + } + } + + if (maxCx < minCx || maxCy < minCy) { + return; + } + + // Reserve a little headroom for the NEXT label, then center the piece in screen-space. + const float labelReserve = tileSize * 0.9f; + const float previewTop = panelY + std::min(labelReserve, panelH * 0.45f); + const float previewBottom = panelY + panelH - tileSize * 0.25f; + const float previewCenterY = (previewTop + previewBottom) * 0.5f; + const float previewCenterX = std::round(panelX + panelW * 0.5f); + + const float pieceWidth = static_cast(maxCx - minCx + 1) * tileSize; + const float pieceHeight = static_cast(maxCy - minCy + 1) * tileSize; + // Center piece so its local cells fall exactly on grid-aligned pixel columns + float startX = previewCenterX - pieceWidth * 0.5f - static_cast(minCx) * tileSize; + float startY = previewCenterY - pieceHeight * 0.5f - static_cast(minCy) * tileSize; + // Snap horizontal position to the playfield's tile grid so preview cells align exactly + // with the main grid columns. `panelX` was computed as `gridX + tileSize` in caller, + // so derive grid origin as `panelX - tileSize`. + float gridOriginX = panelX - tileSize; + float rel = startX - gridOriginX; + float nearestTile = std::round(rel / tileSize); + startX = gridOriginX + nearestTile * tileSize; + // Round Y to pixel to avoid subpixel artifacts and nudge upward slightly + startY = std::round(startY) - 5.0f; + + // If a transfer fade is active, the preview cells will be drawn by the + // transport effect (with fade). Skip drawing the normal preview in that case. + if (!s_transport.active) { + for (int cy = 0; cy < 4; ++cy) { + for (int cx = 0; cx < 4; ++cx) { + if (!Game::cellFilled(nextPiece, cx, cy)) { + continue; + } + const float px = startX + static_cast(cx) * tileSize; + const float py = startY + static_cast(cy) * tileSize; + GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, tileSize, nextPiece.type); + } + } + } +} + void GameRenderer::renderPlayingState( SDL_Renderer* renderer, Game* game, FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* statisticsPanelTex, + SDL_Texture* scorePanelTex, + SDL_Texture* nextPanelTex, float logicalW, float logicalH, float logicalScale, @@ -212,7 +583,6 @@ void GameRenderer::renderPlayingState( 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; const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY; const float statsY = gridY; @@ -220,10 +590,12 @@ void GameRenderer::renderPlayingState( const float statsH = GRID_H; // Next piece preview position - 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; + // Make NEXT panel span the inner area of the grid with a 1-cell margin on both sides + const float NEXT_PANEL_WIDTH = GRID_W - finalBlockSize * 2.0f; // leave 1 cell on left and right + const float NEXT_PANEL_HEIGHT = finalBlockSize * 3.0f; + const float NEXT_PANEL_X = gridX + finalBlockSize; // align panel so there's exactly one cell margin + // Move NEXT panel a bit higher so it visually separates from the grid + const float NEXT_PANEL_Y = gridY - NEXT_PANEL_HEIGHT - 12.0f; // nudge up ~12px // Handle line clearing effects if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) { @@ -233,16 +605,59 @@ void GameRenderer::renderPlayingState( // Draw game grid border drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255}); - drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - 1 - contentOffsetY, GRID_W + 2, GRID_H + 2, {60, 80, 160, 255}); + // Draw a 1px blue border around grid but omit the top horizontal so the NEXT panel + // can visually join seamlessly. We'll draw left, right and bottom bands manually. + { + SDL_Color blue{60, 80, 160, 255}; + // left vertical band (1px wide) + drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - contentOffsetY, 1.0f, GRID_H, blue); + // right vertical band (1px wide) + drawRectWithOffset(gridX + GRID_W - contentOffsetX, gridY - contentOffsetY, 1.0f, GRID_H, blue); + // bottom horizontal band (1px high) + drawRectWithOffset(gridX - 1 - contentOffsetX, gridY + GRID_H - contentOffsetY, GRID_W + 2.0f, 1.0f, blue); + } drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255}); - // 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 stats panel backdrop using the same art as the score panel + const float blocksPanelPadLeft = 34.0f; + const float blocksPanelPadRight = 10.0f; + const float blocksPanelPadY = 26.0f; + SDL_FRect blocksPanelBg{ + statsX - blocksPanelPadLeft, + gridY - blocksPanelPadY, + statsW + blocksPanelPadLeft + blocksPanelPadRight, + GRID_H + blocksPanelPadY * 2.0f + }; + if (statisticsPanelTex) { + // Use the dedicated statistics panel image; scale it to fit the panel while + // preserving aspect ratio (no crop) so very tall source images don't overflow. + float texWf = 0.0f, texHf = 0.0f; + if (SDL_GetTextureSize(statisticsPanelTex, &texWf, &texHf)) { + if (texWf > 0.0f && texHf > 0.0f) { + const float destW = blocksPanelBg.w; + const float destH = blocksPanelBg.h; + const float scale = std::min(destW / texWf, destH / texHf); + const float scaledW = texWf * scale; + const float scaledH = texHf * scale; + // Center the scaled texture inside the panel + SDL_FRect dstF{ + blocksPanelBg.x + (destW - scaledW) * 0.5f, + blocksPanelBg.y + (destH - scaledH) * 0.5f, + scaledW, + scaledH + }; + SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &dstF); + } + } else { + // Fallback: render entire texture stretched to panel + SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &blocksPanelBg); + } + } else if (scorePanelTex) { + SDL_RenderTexture(renderer, scorePanelTex, nullptr, &blocksPanelBg); + } else { + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); + SDL_RenderFillRect(renderer, &blocksPanelBg); + } // Draw grid lines SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); @@ -302,15 +717,137 @@ void GameRenderer::renderPlayingState( SDL_GetRenderDrawBlendMode(renderer, &oldBlend); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); s_inGridStarfield.draw(renderer, gridX, gridY, 0.22f, true); + + // Update and spawn ambient sparkles inside/around the grid + // Use the same RNG and timing values used for impact sparks + if (!game->isPaused()) { + // Spawn rate: ~10 sparks/sec total (adjustable) + const float spawnInterval = 0.08f; // seconds + s_sparkleSpawnAcc += deltaSeconds; + while (s_sparkleSpawnAcc >= spawnInterval) { + s_sparkleSpawnAcc -= spawnInterval; + + Sparkle s; + // Choose spawn area: near active piece magnet if present, otherwise along top/border + bool spawnNearPiece = appliedMagnet && (std::uniform_real_distribution(0.0f,1.0f)(s_impactRng) > 0.35f); + float sx = 0.0f, sy = 0.0f; + if (spawnNearPiece) { + // Use starfield magnet target if set (approx center of active piece) + // Random jitter around magnet + float jitterX = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_impactRng); + float jitterY = std::uniform_real_distribution(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_impactRng); + // s_inGridStarfield stores magnet in local coords when used; approximate from magnet calculations above + // We'll center near grid center if magnet not available + sx = std::clamp(GRID_W * 0.5f + jitterX, -finalBlockSize * 2.0f, GRID_W + finalBlockSize * 2.0f); + sy = std::clamp(GRID_H * 0.4f + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f); + } else { + // Spawn along border: choose side and position + float side = std::uniform_real_distribution(0.0f, 1.0f)(s_impactRng); + // Border band width (how far outside the grid sparks can appear) + const float borderBand = std::max(12.0f, finalBlockSize * 1.0f); + if (side < 0.2f) { // left (outside) + sx = std::uniform_real_distribution(-borderBand, 0.0f)(s_impactRng); + sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_impactRng); + } else if (side < 0.4f) { // right (outside) + sx = std::uniform_real_distribution(GRID_W, GRID_W + borderBand)(s_impactRng); + sy = std::uniform_real_distribution(-borderBand, GRID_H + borderBand)(s_impactRng); + } else if (side < 0.6f) { // top (outside) + sx = std::uniform_real_distribution(-borderBand, GRID_W + borderBand)(s_impactRng); + sy = std::uniform_real_distribution(-borderBand, 0.0f)(s_impactRng); + } else if (side < 0.9f) { // top/inside border area + sx = std::uniform_real_distribution(0.0f, GRID_W)(s_impactRng); + sy = std::uniform_real_distribution(0.0f, finalBlockSize * 2.0f)(s_impactRng); + } else { // bottom (outside) + sx = std::uniform_real_distribution(-borderBand, GRID_W + borderBand)(s_impactRng); + sy = std::uniform_real_distribution(GRID_H, GRID_H + borderBand)(s_impactRng); + } + } + + s.x = sx; + s.y = sy; + float speed = std::uniform_real_distribution(10.0f, 60.0f)(s_impactRng); + float ang = std::uniform_real_distribution(-3.14159f, 3.14159f)(s_impactRng); + s.vx = std::cos(ang) * speed; + s.vy = std::sin(ang) * speed * 0.25f; // slower vertical movement + s.maxLifeMs = std::uniform_real_distribution(350.0f, 900.0f)(s_impactRng); + s.lifeMs = s.maxLifeMs; + s.size = std::uniform_real_distribution(1.5f, 5.0f)(s_impactRng); + // Soft color range towards warm/cyan tints + if (std::uniform_real_distribution(0.0f,1.0f)(s_impactRng) < 0.5f) { + s.color = SDL_Color{255, 230, 180, 255}; + } else { + s.color = SDL_Color{180, 220, 255, 255}; + } + s.pulse = std::uniform_real_distribution(0.0f, 6.28f)(s_impactRng); + + s_sparkles.push_back(s); + } + } + + // Update and draw sparkles + if (!s_sparkles.empty()) { + auto it = s_sparkles.begin(); + while (it != s_sparkles.end()) { + Sparkle &sp = *it; + sp.lifeMs -= sparkDeltaMs; + if (sp.lifeMs <= 0.0f) { + // On expiration, spawn a small burst of ImpactSparks (smaller boxes) + const int burstCount = std::uniform_int_distribution(4, 8)(s_impactRng); + for (int bi = 0; bi < burstCount; ++bi) { + ImpactSpark ps; + // Position in absolute coords (same space as other impact sparks) + ps.x = gridX + sp.x + std::uniform_real_distribution(-2.0f, 2.0f)(s_impactRng); + ps.y = gridY + sp.y + std::uniform_real_distribution(-2.0f, 2.0f)(s_impactRng); + float ang = std::uniform_real_distribution(0.0f, 6.2831853f)(s_impactRng); + float speed = std::uniform_real_distribution(10.0f, 120.0f)(s_impactRng); + ps.vx = std::cos(ang) * speed; + ps.vy = std::sin(ang) * speed * 0.8f; + ps.maxLifeMs = std::uniform_real_distribution(220.0f, 500.0f)(s_impactRng); + ps.lifeMs = ps.maxLifeMs; + ps.size = std::max(1.0f, sp.size * 0.5f); + ps.color = sp.color; + s_impactSparks.push_back(ps); + } + + it = s_sparkles.erase(it); + continue; + } + float lifeRatio = sp.lifeMs / sp.maxLifeMs; + // simple motion + sp.x += sp.vx * deltaSeconds; + sp.y += sp.vy * deltaSeconds; + sp.vy *= 0.995f; // slight damping + sp.pulse += deltaSeconds * 8.0f; + + // Fade and pulse alpha + float pulse = 0.5f + 0.5f * std::sin(sp.pulse); + Uint8 alpha = static_cast(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha); + float half = sp.size * 0.5f; + SDL_FRect fr{gridX + sp.x - half, gridY + sp.y - half, sp.size, sp.size}; + SDL_RenderFillRect(renderer, &fr); + + ++it; + } + } + SDL_SetRenderDrawBlendMode(renderer, oldBlend); - - // Draw block statistics panel border - drawRectWithOffset(statsX - 3 - contentOffsetX, statsY - 3 - contentOffsetY, statsW + 6, statsH + 6, {100, 120, 200, 255}); - drawRectWithOffset(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255}); - - // Draw next piece preview panel border - 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}); + + renderNextPanel(renderer, pixelFont, blocksTex, nextPanelTex, game->next(), NEXT_PANEL_X, NEXT_PANEL_Y, NEXT_PANEL_WIDTH, NEXT_PANEL_HEIGHT, finalBlockSize); + + // Draw a small filled connector to visually merge NEXT panel and grid border + // If an external NEXT panel texture is used, skip the connector to avoid + // drawing a visible seam under the image/artwork. + if (!nextPanelTex) { + SDL_SetRenderDrawColor(renderer, 60, 80, 160, 255); // same as grid border + float connectorY = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT; // bottom of next panel (near grid top) + SDL_FRect connRect{ NEXT_PANEL_X, connectorY - 1.0f, NEXT_PANEL_WIDTH, 2.0f }; + SDL_RenderFillRect(renderer, &connRect); + } + + // Draw transport effect if active (renders the moving piece and trail) + updateAndDrawTransport(renderer, blocksTex); // Precompute row drop offsets (line collapse effect) std::array rowDropOffsets{}; @@ -439,7 +976,7 @@ void GameRenderer::renderPlayingState( } } - bool allowActivePieceRender = true; + bool allowActivePieceRender = !GameRenderer::isTransportActive(); const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled(); float activePiecePixelOffsetX = 0.0f; @@ -560,89 +1097,159 @@ void GameRenderer::renderPlayingState( lineEffect->render(renderer, blocksTex, static_cast(gridX), static_cast(gridY), static_cast(finalBlockSize)); } - // Draw next piece preview - pixelFont->draw(renderer, nextX + 10, nextY - 20, "NEXT", 1.0f, {255, 220, 0, 255}); - if (game->next().type < PIECE_COUNT) { - drawSmallPiece(renderer, blocksTex, static_cast(game->next().type), nextX + 10, nextY + 10, finalBlockSize * 0.6f); - } - - // Draw block statistics (left panel) - pixelFont->draw(renderer, statsX + 10, statsY + 10, "BLOCKS", 1.0f, {255, 220, 0, 255}); - + // Draw block statistics (left panel) -> STATISTICS console const auto& blockCounts = game->getBlockCounts(); int totalBlocks = 0; for (int i = 0; i < PIECE_COUNT; ++i) totalBlocks += blockCounts[i]; - const float rowPadding = 18.0f; - const float rowWidth = statsW - rowPadding * 2.0f; - const float rowSpacing = 12.0f; - float yCursor = statsY + 44.0f; + // Header (slightly smaller) + const SDL_Color headerColor{255, 220, 0, 255}; + const SDL_Color textColor{200, 220, 235, 200}; + const SDL_Color mutedColor{150, 180, 200, 180}; + // Small vertical shift to push text further into the statistics panel + // Increased from 20 to 80 to move text down by ~60px more (within 50-100px range) + const float statsTextShift = 80.0f; + pixelFont->draw(renderer, statsX + 12.0f, statsY + 8.0f + statsTextShift, "STATISTICS", 0.70f, headerColor); + // Tighter spacing and smaller icons/text for compact analytics console + float yCursor = statsY + 34.0f + statsTextShift; + const float leftPad = 2.0f; + const float rightPad = 14.0f; + // Horizontal shift to nudge the count/percent group closer to the right edge + const float statsNumbersShift = 20.0f; + // Horizontal shift to move the progress bar slightly left + const float statsBarShift = -10.0f; + // Increase row gap to avoid icon overlap on smaller scales (bumped up) + const float rowGap = 28.0f; + const float barHeight = 2.0f; + + // Text scales for per-row numbers (slightly larger than before) + const float countScale = 0.60f; + const float percScale = 0.60f; + + // Determine max percent to highlight top used piece + int maxPerc = 0; for (int i = 0; i < PIECE_COUNT; ++i) { - float rowTop = yCursor; - float previewSize = finalBlockSize * 0.52f; - float previewX = statsX + rowPadding; - float previewY = rowTop - 14.0f; + int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(blockCounts[i]) / double(totalBlocks))) : 0; + if (perc > maxPerc) maxPerc = perc; + } - // Determine actual preview height to keep bars below the blocks - Game::Piece previewPiece{}; - previewPiece.type = static_cast(i); - int maxCy = -1; - for (int cy = 0; cy < 4; ++cy) { - for (int cx = 0; cx < 4; ++cx) { - if (Game::cellFilled(previewPiece, cx, cy)) { - maxCy = std::max(maxCy, cy); - } - } - } - float pieceHeight = (maxCy >= 0 ? maxCy + 1.0f : 1.0f) * previewSize; + // Row order groups: first 4, then last 3 + std::vector order = {0,1,2,3, 4,5,6}; + // Precompute max widths for counts/perc so right-alignment is stable + int maxCountW = 0, maxCountH = 0; + int maxPercW = 0, maxPercH = 0; + for (int oi : order) { + char tmpCount[16]; snprintf(tmpCount, sizeof(tmpCount), "%dx", blockCounts[oi]); + char tmpPerc[16]; int tmpPercVal = (totalBlocks > 0) ? int(std::round(100.0 * double(blockCounts[oi]) / double(totalBlocks))) : 0; snprintf(tmpPerc, sizeof(tmpPerc), "%d%%", tmpPercVal); + pixelFont->measure(tmpCount, countScale, maxCountW, maxCountH); + int pw=0, ph=0; pixelFont->measure(tmpPerc, percScale, pw, ph); + maxPercW = std::max(maxPercW, pw); + maxPercH = std::max(maxPercH, ph); + } + for (size_t idx = 0; idx < order.size(); ++idx) { + int i = order[idx]; + + float rowLeft = statsX + leftPad; + float rowRight = statsX + statsW - rightPad; + + // Icon aligned left, slightly higher to match reference spacing + float iconSize = finalBlockSize * 0.52f; + float iconBgPad = 6.0f; + float iconBgX = rowLeft - 18.0f; + float iconBgY = yCursor - 10.0f; + + // Measure right-side text first so we can vertically align icon with text int count = blockCounts[i]; - char countStr[16]; - snprintf(countStr, sizeof(countStr), "%d", count); - int countW = 0, countH = 0; - pixelFont->measure(countStr, 1.0f, countW, countH); - float countX = previewX + rowWidth - static_cast(countW); - float countY = previewY + 9.0f; - + char countStr[16]; snprintf(countStr, sizeof(countStr), "%dx", count); int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(count) / double(totalBlocks))) : 0; - char percStr[16]; - snprintf(percStr, sizeof(percStr), "%d%%", perc); + char percStr[16]; snprintf(percStr, sizeof(percStr), "%d%%", perc); - float barX = previewX; - float barY = previewY + pieceHeight + 12.0f; - float barH = 6.0f; - float barW = rowWidth; - float percY = barY + barH + 8.0f; + int countW = maxCountW, countH = maxCountH; + int percW = maxPercW, percH = maxPercH; - float rowBottom = percY + 16.0f; - SDL_FRect rowBg{ - previewX - 12.0f, - rowTop - 14.0f, - rowWidth + 24.0f, - rowBottom - (rowTop - 14.0f) - }; - SDL_SetRenderDrawColor(renderer, 18, 26, 40, 200); - SDL_RenderFillRect(renderer, &rowBg); - SDL_SetRenderDrawColor(renderer, 70, 100, 150, 210); - SDL_RenderRect(renderer, &rowBg); + float iconX = iconBgX + iconBgPad; + float iconY = yCursor - 6.0f + ((float)countH - iconSize) * 0.5f; + drawSmallPiece(renderer, blocksTex, static_cast(i), iconX, iconY, iconSize); - drawSmallPiece(renderer, blocksTex, static_cast(i), previewX, previewY, previewSize); - pixelFont->draw(renderer, countX, countY, countStr, 1.0f, {245, 245, 255, 255}); - pixelFont->draw(renderer, previewX, percY, percStr, 0.8f, {215, 225, 240, 255}); + // Badge for counts/percent with no background, aligned to the right + const float numbersGap = 14.0f; + const float numbersPadX = 10.0f; + const float numbersPadY = 6.0f; + int maxTextH = std::max(countH, percH); + float numbersW = numbersPadX * 2.0f + countW + numbersGap + percW; + float numbersH = numbersPadY * 2.0f + static_cast(maxTextH); + float numbersX = rowRight - numbersW + statsNumbersShift; + float numbersY = yCursor - (numbersH - static_cast(maxTextH)) * 0.5f; - SDL_SetRenderDrawColor(renderer, 110, 120, 140, 200); - SDL_FRect track{barX, barY, barW, barH}; + float textY = numbersY + (numbersH - static_cast(maxTextH)) * 0.5f; + // Right-align the numbers block: perc anchored to the right, count right-aligned to the perc with gap + float percX = numbersX + numbersW - percW - numbersPadX; + float countX = percX - numbersGap - countW; + pixelFont->draw(renderer, countX, textY, countStr, countScale, textColor); + pixelFont->draw(renderer, percX, textY, percStr, percScale, mutedColor); + + // Progress bar anchored to the numbers width + float barX = numbersX + statsBarShift; + float barW = numbersW; + float barY = numbersY + numbersH + 8.0f; + + SDL_SetRenderDrawColor(renderer, 24, 80, 120, 220); + SDL_FRect track{barX, barY, barW, barHeight}; SDL_RenderFillRect(renderer, &track); - SDL_Color pc = COLORS[i + 1]; - SDL_SetRenderDrawColor(renderer, pc.r, pc.g, pc.b, 255); - float fillW = barW * (perc / 100.0f); - fillW = std::clamp(fillW, 0.0f, barW); - SDL_FRect fill{barX, barY, fillW, barH}; + + // Fill color brightness based on usage and highlight for top piece + float strength = (totalBlocks > 0) ? (float(blockCounts[i]) / float(totalBlocks)) : 0.0f; + SDL_Color baseC = {60, 200, 255, 255}; + SDL_Color dimC = {40, 120, 160, 255}; + SDL_Color fillC = (perc == maxPerc) ? SDL_Color{100, 230, 255, 255} : SDL_Color{ + static_cast(std::lerp((float)dimC.r, (float)baseC.r, strength)), + static_cast(std::lerp((float)dimC.g, (float)baseC.g, strength)), + static_cast(std::lerp((float)dimC.b, (float)baseC.b, strength)), + 255 + }; + + float fillW = barW * std::clamp(strength, 0.0f, 1.0f); + SDL_SetRenderDrawColor(renderer, fillC.r, fillC.g, fillC.b, fillC.a); + SDL_FRect fill{barX, barY, fillW, barHeight}; SDL_RenderFillRect(renderer, &fill); - yCursor = rowBottom + rowSpacing; + // Advance cursor to next row: after bar + gap (leave more space between blocks) + yCursor = barY + barHeight + rowGap + 6.0f; } + + // Bottom summary stats: prefer to place summary right after last row + // but clamp it so it never goes below the reserved bottom area. + float preferredSummaryY = yCursor + 24.0f; // space below last row + float maxSummaryY = statsY + statsH - 90.0f; // original lower bound + float summaryY = std::min(preferredSummaryY, maxSummaryY); + const SDL_Color summaryValueColor{220, 235, 250, 255}; + const SDL_Color labelMuted{160, 180, 200, 200}; + + char totalStr[32]; snprintf(totalStr, sizeof(totalStr), "%d", totalBlocks); + char tetrisesStr[32]; snprintf(tetrisesStr, sizeof(tetrisesStr), "%d", game->tetrisesMade()); + char maxComboStr[32]; snprintf(maxComboStr, sizeof(maxComboStr), "%d", game->comboCount()); + + // Use slightly smaller labels/values to match the compact look + const float labelX = statsX + 8.0f; // move labels more left + const float valueRightPad = 12.0f; // pad from right edge + + int valW=0, valH=0; + pixelFont->measure(totalStr, 0.55f, valW, valH); + float totalX = statsX + statsW - valueRightPad - (float)valW; + pixelFont->draw(renderer, labelX, summaryY + 0.0f, "TOTAL PIECES", 0.46f, labelMuted); + pixelFont->draw(renderer, totalX, summaryY + 0.0f, totalStr, 0.55f, summaryValueColor); + + pixelFont->measure(tetrisesStr, 0.55f, valW, valH); + float tetrisesX = statsX + statsW - valueRightPad - (float)valW; + pixelFont->draw(renderer, labelX, summaryY + 22.0f, "TETRISES MADE", 0.46f, labelMuted); + pixelFont->draw(renderer, tetrisesX, summaryY + 22.0f, tetrisesStr, 0.55f, summaryValueColor); + + pixelFont->measure(maxComboStr, 0.55f, valW, valH); + float comboX = statsX + statsW - valueRightPad - (float)valW; + pixelFont->draw(renderer, labelX, summaryY + 44.0f, "COMBOS", 0.46f, labelMuted); + pixelFont->draw(renderer, comboX, summaryY + 44.0f, maxComboStr, 0.55f, summaryValueColor); // Draw score panel (right side) const float contentTopOffset = 0.0f; @@ -650,22 +1257,27 @@ void GameRenderer::renderPlayingState( 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}); + + const float statsPanelGap = 12.0f; + const float statsPanelLeft = gridX + GRID_W + statsPanelGap; + const float statsPanelPadLeft = 40.0f; + const float statsPanelPadRight = 34.0f; + const float statsPanelPadY = 28.0f; + const float statsTextX = statsPanelLeft + statsPanelPadLeft; + + const SDL_Color labelColor{255, 220, 0, 255}; + const SDL_Color valueColor{255, 255, 255, 255}; + const SDL_Color nextColor{80, 255, 120, 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; @@ -678,46 +1290,98 @@ void GameRenderer::renderPlayingState( nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10; } int linesForNext = std::max(0, nextThreshold - linesDone); - pixelFont->draw(renderer, scoreX, baseY + 200, "NEXT LVL", 1.0f, {255, 220, 0, 255}); char nextStr[32]; snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext); - pixelFont->draw(renderer, scoreX, baseY + 225, nextStr, 0.9f, {80, 255, 120, 255}); - + // Time display - pixelFont->draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255}); int totalSecs = static_cast(game->elapsed()); int mins = totalSecs / 60; int secs = totalSecs % 60; char timeStr[16]; snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs); - pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255}); - - // Debug: Gravity timing info - if (Settings::instance().isDebugEnabled()) { - pixelFont->draw(renderer, scoreX, baseY + 330, "GRAVITY", 0.8f, {150, 150, 150, 255}); + + const bool debugEnabled = Settings::instance().isDebugEnabled(); + char gravityStr[32] = ""; + char dropStr[32] = ""; + char gravityHud[64] = ""; + SDL_Color dropColor{100, 255, 100, 255}; + if (debugEnabled) { double gravityMs = game->getGravityMs(); double fallAcc = game->getFallAccumulator(); - - // Calculate effective gravity (accounting for soft drop) bool isSoftDrop = game->isSoftDropping(); double effectiveGravityMs = isSoftDrop ? (gravityMs / 2.0) : gravityMs; double timeUntilDrop = std::max(0.0, effectiveGravityMs - fallAcc); - - char gravityStr[32]; + snprintf(gravityStr, sizeof(gravityStr), "%.0f ms%s", gravityMs, isSoftDrop ? " (SD)" : ""); - pixelFont->draw(renderer, scoreX, baseY + 350, gravityStr, 0.7f, {180, 180, 180, 255}); - - char dropStr[32]; snprintf(dropStr, sizeof(dropStr), "Drop: %.0f", timeUntilDrop); - SDL_Color dropColor = isSoftDrop ? SDL_Color{255, 200, 100, 255} : SDL_Color{100, 255, 100, 255}; - pixelFont->draw(renderer, scoreX, baseY + 370, dropStr, 0.7f, dropColor); - - // Gravity HUD (Top) - char gms[64]; - double gms_val = game->getGravityMs(); - double gfps = gms_val > 0.0 ? (1000.0 / gms_val) : 0.0; - snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps); - pixelFont->draw(renderer, logicalW - 260, 10, gms, 0.9f, {200, 200, 220, 255}); + dropColor = isSoftDrop ? SDL_Color{255, 200, 100, 255} : SDL_Color{100, 255, 100, 255}; + + double gfps = gravityMs > 0.0 ? (1000.0 / gravityMs) : 0.0; + snprintf(gravityHud, sizeof(gravityHud), "GRAV: %.0f ms (%.2f fps)", gravityMs, gfps); + } + + struct StatLine { + const char* text; + float offsetY; + float scale; + SDL_Color color; + }; + + std::vector statLines; + statLines.reserve(debugEnabled ? 13 : 10); + statLines.push_back({"SCORE", 0.0f, 1.0f, labelColor}); + statLines.push_back({scoreStr, 25.0f, 0.9f, valueColor}); + statLines.push_back({"LINES", 70.0f, 1.0f, labelColor}); + statLines.push_back({linesStr, 95.0f, 0.9f, valueColor}); + statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor}); + statLines.push_back({levelStr, 165.0f, 0.9f, valueColor}); + statLines.push_back({"NEXT LVL", 200.0f, 1.0f, labelColor}); + statLines.push_back({nextStr, 225.0f, 0.9f, nextColor}); + statLines.push_back({"TIME", 265.0f, 1.0f, labelColor}); + statLines.push_back({timeStr, 290.0f, 0.9f, valueColor}); + + if (debugEnabled) { + SDL_Color debugLabelColor{150, 150, 150, 255}; + SDL_Color debugValueColor{180, 180, 180, 255}; + statLines.push_back({"GRAVITY", 330.0f, 0.8f, debugLabelColor}); + statLines.push_back({gravityStr, 350.0f, 0.7f, debugValueColor}); + statLines.push_back({dropStr, 370.0f, 0.7f, dropColor}); + } + + if (!statLines.empty()) { + float statsContentTop = std::numeric_limits::max(); + float statsContentBottom = std::numeric_limits::lowest(); + float statsContentMaxWidth = 0.0f; + + for (const auto& line : statLines) { + int textW = 0; + int textH = 0; + pixelFont->measure(line.text, line.scale, textW, textH); + float y = baseY + line.offsetY; + statsContentTop = std::min(statsContentTop, y); + statsContentBottom = std::max(statsContentBottom, y + textH); + statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast(textW)); + } + + float statsPanelWidth = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight; + float statsPanelHeight = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f; + float statsPanelTop = statsContentTop - statsPanelPadY; + + SDL_FRect statsBg{statsPanelLeft, statsPanelTop, statsPanelWidth, statsPanelHeight}; + if (scorePanelTex) { + SDL_RenderTexture(renderer, scorePanelTex, nullptr, &statsBg); + } else { + SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); + SDL_RenderFillRect(renderer, &statsBg); + } + } + + for (const auto& line : statLines) { + pixelFont->draw(renderer, statsTextX, baseY + line.offsetY, line.text, line.scale, line.color); + } + + if (debugEnabled) { + pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255}); } // Hold piece (if implemented) @@ -740,120 +1404,165 @@ void GameRenderer::renderExitPopup( float logicalScale, int selectedButton ) { - // Calculate content offsets (same as in renderPlayingState for consistency) - // We need to re-calculate them or pass them in? - // The popup uses logical coordinates centered on screen. - // Let's use the same logic as renderPauseOverlay (window coordinates) to be safe and consistent? - // The original code used logical coordinates + contentOffset. - // Let's stick to the original look but render it in window coordinates to ensure it covers everything properly. - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); - // Switch to window coordinates SDL_Rect oldViewport; SDL_GetRenderViewport(renderer, &oldViewport); - float oldScaleX, oldScaleY; + float oldScaleX = 1.0f; + float oldScaleY = 1.0f; SDL_GetRenderScale(renderer, &oldScaleX, &oldScaleY); SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderScale(renderer, 1.0f, 1.0f); - // Full screen dim - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 200); - SDL_FRect fullWin{0.f, 0.f, winW, winH}; + SDL_SetRenderDrawColor(renderer, 2, 4, 12, 210); + SDL_FRect fullWin{0.0f, 0.0f, winW, winH}; SDL_RenderFillRect(renderer, &fullWin); - // Calculate panel position (centered in window) - // Original was logicalW based, let's map it to window size. - // Logical 640x320 scaled up. - float panelW = 640.0f * logicalScale; - float panelH = 320.0f * logicalScale; - float panelX = (winW - panelW) * 0.5f; - float panelY = (winH - panelH) * 0.5f; + const float scale = std::max(0.8f, logicalScale); + const float panelW = 740.0f * scale; + const float panelH = 380.0f * scale; + SDL_FRect panel{ + (winW - panelW) * 0.5f, + (winH - panelH) * 0.5f, + panelW, + panelH + }; - SDL_FRect panel{panelX, panelY, panelW, panelH}; - - SDL_FRect shadow{panel.x + 6.0f * logicalScale, panel.y + 10.0f * logicalScale, panel.w, panel.h}; + SDL_FRect shadow{ + panel.x + 14.0f * scale, + panel.y + 16.0f * scale, + panel.w + 4.0f * scale, + panel.h + 4.0f * scale + }; SDL_SetRenderDrawColor(renderer, 0, 0, 0, 140); SDL_RenderFillRect(renderer, &shadow); - for (int i = 0; i < 5; ++i) { - float off = float(i * 2) * logicalScale; - float exp = float(i * 4) * logicalScale; - SDL_FRect glow{panel.x - off, panel.y - off, panel.w + exp, panel.h + exp}; - SDL_SetRenderDrawColor(renderer, 0, 180, 255, Uint8(44 - i * 7)); - SDL_RenderRect(renderer, &glow); + const std::array panelLayers{ + SDL_Color{7, 10, 22, 255}, + SDL_Color{12, 22, 40, 255}, + SDL_Color{18, 32, 56, 255} + }; + for (size_t i = 0; i < panelLayers.size(); ++i) { + float inset = static_cast(i) * 6.0f * scale; + SDL_FRect layer{ + panel.x + inset, + panel.y + inset, + panel.w - inset * 2.0f, + panel.h - inset * 2.0f + }; + SDL_Color c = panelLayers[i]; + SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); + SDL_RenderFillRect(renderer, &layer); } - SDL_SetRenderDrawColor(renderer, 18, 30, 52, 255); - SDL_RenderFillRect(renderer, &panel); - SDL_SetRenderDrawColor(renderer, 70, 120, 210, 255); + SDL_SetRenderDrawColor(renderer, 60, 90, 150, 255); SDL_RenderRect(renderer, &panel); - SDL_FRect inner{panel.x + 24.0f * logicalScale, panel.y + 98.0f * logicalScale, panel.w - 48.0f * logicalScale, panel.h - 146.0f * logicalScale}; - SDL_SetRenderDrawColor(renderer, 16, 24, 40, 235); - SDL_RenderFillRect(renderer, &inner); - SDL_SetRenderDrawColor(renderer, 40, 80, 140, 235); - SDL_RenderRect(renderer, &inner); + SDL_FRect insetFrame{ + panel.x + 10.0f * scale, + panel.y + 10.0f * scale, + panel.w - 20.0f * scale, + panel.h - 20.0f * scale + }; + SDL_SetRenderDrawColor(renderer, 24, 45, 84, 255); + SDL_RenderRect(renderer, &insetFrame); - const std::string title = "EXIT GAME?"; + const float contentPad = 44.0f * scale; + float textX = panel.x + contentPad; + float contentWidth = panel.w - contentPad * 2.0f; + float cursorY = panel.y + contentPad * 0.6f; + + const char* title = "EXIT GAME?"; + const float titleScale = 2.0f * scale; int titleW = 0, titleH = 0; - const float titleScale = 1.8f * logicalScale; pixelFont->measure(title, titleScale, titleW, titleH); - pixelFont->draw(renderer, panel.x + (panel.w - titleW) * 0.5f, panel.y + 30.0f * logicalScale, title, titleScale, {255, 230, 140, 255}); + pixelFont->draw(renderer, textX, cursorY, title, titleScale, SDL_Color{255, 224, 130, 255}); + cursorY += titleH + 18.0f * scale; - std::array lines = { + SDL_SetRenderDrawColor(renderer, 32, 64, 110, 210); + SDL_FRect divider{textX, cursorY, contentWidth, 2.0f * scale}; + SDL_RenderFillRect(renderer, ÷r); + cursorY += 26.0f * scale; + + const std::array lines{ "Are you sure you want to quit?", "Current progress will be lost." }; - float lineY = inner.y + 22.0f * logicalScale; - const float lineScale = 1.05f * logicalScale; - for (const auto& line : lines) { + const float bodyScale = 1.05f * scale; + for (const char* line : lines) { int lineW = 0, lineH = 0; - pixelFont->measure(line, lineScale, lineW, lineH); - float textX = panel.x + (panel.w - lineW) * 0.5f; - pixelFont->draw(renderer, textX, lineY, line, lineScale, SDL_Color{210, 220, 240, 255}); - lineY += lineH + 10.0f * logicalScale; + pixelFont->measure(line, bodyScale, lineW, lineH); + pixelFont->draw(renderer, textX, cursorY, line, bodyScale, SDL_Color{210, 226, 245, 255}); + cursorY += lineH + 10.0f * scale; } - const float horizontalPad = 28.0f * logicalScale; - const float buttonGap = 32.0f * logicalScale; - const float buttonH = 66.0f * logicalScale; - float buttonW = (inner.w - horizontalPad * 2.0f - buttonGap) * 0.5f; - float buttonY = inner.y + inner.h - buttonH - 24.0f * logicalScale; + const char* tip = "Enter confirms โ€ข Esc returns"; + const float tipScale = 0.9f * scale; + int tipW = 0, tipH = 0; + pixelFont->measure(tip, tipScale, tipW, tipH); - auto drawButton = [&](int idx, float x, const char* label) { + const float buttonGap = 32.0f * scale; + const float buttonH = 78.0f * scale; + const float buttonW = (contentWidth - buttonGap) * 0.5f; + float buttonY = panel.y + panel.h - contentPad - buttonH; + + float tipX = panel.x + (panel.w - tipW) * 0.5f; + float tipY = buttonY - tipH - 14.0f * scale; + pixelFont->draw(renderer, tipX, tipY, tip, tipScale, SDL_Color{150, 170, 205, 255}); + + auto drawButton = [&](int idx, float btnX, SDL_Color baseColor, const char* label) { bool selected = (selectedButton == idx); - SDL_Color base = (idx == 0) ? SDL_Color{185, 70, 70, 255} : SDL_Color{60, 95, 150, 255}; - SDL_Color body = selected ? SDL_Color{Uint8(std::min(255, base.r + 35)), Uint8(std::min(255, base.g + 35)), Uint8(std::min(255, base.b + 35)), 255} : base; - SDL_Color border = selected ? SDL_Color{255, 220, 120, 255} : SDL_Color{80, 110, 160, 255}; + SDL_FRect btn{btnX, buttonY, buttonW, buttonH}; + SDL_Color body = baseColor; + if (selected) { + body.r = Uint8(std::min(255, body.r + 35)); + body.g = Uint8(std::min(255, body.g + 35)); + body.b = Uint8(std::min(255, body.b + 35)); + } + SDL_Color border = selected ? SDL_Color{255, 225, 150, 255} : SDL_Color{90, 120, 170, 255}; + SDL_Color topEdge = SDL_Color{Uint8(std::min(255, body.r + 20)), Uint8(std::min(255, body.g + 20)), Uint8(std::min(255, body.b + 20)), 255}; - SDL_FRect btn{x, buttonY, buttonW, buttonH}; - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 120); - SDL_FRect btnShadow{btn.x + 4.0f * logicalScale, btn.y + 6.0f * logicalScale, btn.w, btn.h}; + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 110); + SDL_FRect btnShadow{btn.x + 6.0f * scale, btn.y + 8.0f * scale, btn.w, btn.h}; SDL_RenderFillRect(renderer, &btnShadow); + SDL_SetRenderDrawColor(renderer, body.r, body.g, body.b, body.a); SDL_RenderFillRect(renderer, &btn); + + SDL_FRect topStrip{btn.x, btn.y, btn.w, 6.0f * scale}; + SDL_SetRenderDrawColor(renderer, topEdge.r, topEdge.g, topEdge.b, topEdge.a); + SDL_RenderFillRect(renderer, &topStrip); + SDL_SetRenderDrawColor(renderer, border.r, border.g, border.b, border.a); SDL_RenderRect(renderer, &btn); - int textW = 0, textH = 0; - const float labelScale = 1.4f * logicalScale; - pixelFont->measure(label, labelScale, textW, textH); - float textX = btn.x + (btn.w - textW) * 0.5f; - float textY = btn.y + (btn.h - textH) * 0.5f; - SDL_Color textColor = selected ? SDL_Color{255, 255, 255, 255} : SDL_Color{230, 235, 250, 255}; + if (selected) { + SDL_SetRenderDrawColor(renderer, 255, 230, 160, 90); + SDL_FRect glow{ + btn.x - 6.0f * scale, + btn.y - 6.0f * scale, + btn.w + 12.0f * scale, + btn.h + 12.0f * scale + }; + SDL_RenderRect(renderer, &glow); + } + + const float labelScale = 1.35f * scale; + int labelW = 0, labelH = 0; + pixelFont->measure(label, labelScale, labelW, labelH); + float textX = btn.x + (btn.w - labelW) * 0.5f; + float textY = btn.y + (btn.h - labelH) * 0.5f; + SDL_Color textColor = selected ? SDL_Color{255, 255, 255, 255} : SDL_Color{235, 238, 250, 255}; pixelFont->draw(renderer, textX, textY, label, labelScale, textColor); }; - float yesX = inner.x + horizontalPad; + float yesX = textX; float noX = yesX + buttonW + buttonGap; - drawButton(0, yesX, "YES"); - drawButton(1, noX, "NO"); + drawButton(0, yesX, SDL_Color{190, 70, 70, 255}, "YES"); + drawButton(1, noX, SDL_Color{70, 115, 190, 255}, "NO"); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); - - // Restore previous render state SDL_SetRenderViewport(renderer, &oldViewport); SDL_SetRenderScale(renderer, oldScaleX, oldScaleY); } diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index 96422cf..14fd746 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -21,6 +21,9 @@ public: FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* statisticsPanelTex, + SDL_Texture* scorePanelTex, + SDL_Texture* nextPanelTex, float logicalW, float logicalH, float logicalScale, @@ -47,11 +50,27 @@ public: int selectedButton ); + // Public wrapper that forwards to the private tile-drawing helper. Use this if + // calling from non-member helper functions (e.g. visual effects) that cannot + // access private class members. + static void drawBlockTexturePublic(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType); + // Transport/teleport visual effect API (public): start a sci-fi "transport" animation + // moving a visual copy of `piece` from screen pixel origin (startX,startY) to + // target pixel origin (targetX,targetY). `tileSize` should be the same cell size + // used for the grid. Duration is seconds. + static void startTransportEffect(const Game::Piece& piece, float startX, float startY, float targetX, float targetY, float tileSize, float durationSeconds = 0.6f); + // Convenience: compute the preview & grid positions using the same layout math + // used by `renderPlayingState` and start the transport effect for the current + // `game` using renderer layout parameters. + static void startTransportEffectForGame(Game* game, SDL_Texture* blocksTex, float logicalW, float logicalH, float logicalScale, float winW, float winH, float durationSeconds = 0.6f); + static bool isTransportActive(); + private: // Helper functions for drawing game elements static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType); static void drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost = false, float pixelOffsetX = 0.0f, float pixelOffsetY = 0.0f); static void drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize); + static void renderNextPanel(SDL_Renderer* renderer, FontAtlas* pixelFont, SDL_Texture* blocksTex, SDL_Texture* nextPanelTex, const Game::Piece& nextPiece, float panelX, float panelY, float panelW, float panelH, float tileSize); // Helper function for drawing rectangles static void drawRect(SDL_Renderer* renderer, float x, float y, float w, float h, SDL_Color c); diff --git a/src/graphics/renderers/UIRenderer.cpp b/src/graphics/renderers/UIRenderer.cpp new file mode 100644 index 0000000..e86e754 --- /dev/null +++ b/src/graphics/renderers/UIRenderer.cpp @@ -0,0 +1,202 @@ +#include "UIRenderer.h" +#include "../ui/Font.h" +#include +#include + +void UIRenderer::drawSciFiPanel(SDL_Renderer* renderer, const SDL_FRect& rect, float alpha) { + if (!renderer) return; + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + Uint8 alphaUint = static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f)); + + // Drop shadow + SDL_FRect shadow{rect.x + 6.0f, rect.y + 10.0f, rect.w, rect.h}; + SDL_SetRenderDrawColor(renderer, 0, 0, 0, static_cast(120.0f * alpha)); + SDL_RenderFillRect(renderer, &shadow); + + // Glow aura + for (int i = 0; i < 5; ++i) { + SDL_FRect glow{rect.x - float(i * 2), rect.y - float(i * 2), rect.w + float(i * 4), rect.h + float(i * 4)}; + Uint8 glowAlpha = static_cast((42 - i * 8) * alpha); + SDL_SetRenderDrawColor(renderer, 0, 180, 255, glowAlpha); + SDL_RenderRect(renderer, &glow); + } + + // Body + SDL_SetRenderDrawColor(renderer, 18, 30, 52, alphaUint); + SDL_RenderFillRect(renderer, &rect); + + // Border + SDL_SetRenderDrawColor(renderer, 70, 120, 210, alphaUint); + SDL_RenderRect(renderer, &rect); +} + +void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, float cy, float w, float h, + const std::string& label, bool isHovered, bool isSelected, + SDL_Color bgColor, SDL_Color borderColor, bool textOnly, SDL_Texture* icon) { + if (!renderer) return; + + float x = cx - w * 0.5f; + float y = cy - h * 0.5f; + + if (!textOnly) { + // Adjust colors based on state + if (isSelected) { + bgColor = {160, 190, 255, 255}; + SDL_SetRenderDrawColor(renderer, 255, 220, 0, 110); + SDL_FRect glow{x - 10, y - 10, w + 20, h + 20}; + SDL_RenderFillRect(renderer, &glow); + } else if (isHovered) { + bgColor = {static_cast(std::min(255, bgColor.r + 40)), + static_cast(std::min(255, bgColor.g + 40)), + static_cast(std::min(255, bgColor.b + 40)), + bgColor.a}; + } + + // Neon glow aura around the button to increase visibility (subtle) + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + for (int gi = 0; gi < 3; ++gi) { + float grow = 6.0f + gi * 3.0f; + Uint8 glowA = static_cast(std::max(0, (int)borderColor.a / (3 - gi))); + SDL_SetRenderDrawColor(renderer, borderColor.r, borderColor.g, borderColor.b, glowA); + SDL_FRect glowRect{x - grow, y - grow, w + grow * 2.0f, h + grow * 2.0f}; + SDL_RenderRect(renderer, &glowRect); + } + + // Draw button background with border + SDL_SetRenderDrawColor(renderer, borderColor.r, borderColor.g, borderColor.b, borderColor.a); + SDL_FRect borderRect{x - 2, y - 2, w + 4, h + 4}; + SDL_RenderFillRect(renderer, &borderRect); + + SDL_SetRenderDrawColor(renderer, bgColor.r, bgColor.g, bgColor.b, bgColor.a); + SDL_FRect bgRect{x, y, w, h}; + SDL_RenderFillRect(renderer, &bgRect); + } + + // Draw icon if provided, otherwise draw text + if (icon) { + // Get icon dimensions + float iconW = 0.0f, iconH = 0.0f; + SDL_GetTextureSize(icon, &iconW, &iconH); + + // Scale icon to fit nicely in button (60% of button height) + float maxIconH = h * 0.6f; + float scale = maxIconH / iconH; + float scaledW = iconW * scale; + float scaledH = iconH * scale; + + // Center icon in button + float iconX = cx - scaledW * 0.5f; + float iconY = cy - scaledH * 0.5f; + + // Apply yellow tint when selected + if (isSelected) { + SDL_SetTextureColorMod(icon, 255, 220, 0); + } else { + SDL_SetTextureColorMod(icon, 255, 255, 255); + } + + SDL_FRect iconRect{iconX, iconY, scaledW, scaledH}; + SDL_RenderTexture(renderer, icon, nullptr, &iconRect); + + // Reset color mod + SDL_SetTextureColorMod(icon, 255, 255, 255); + } else if (font) { + // Draw text (smaller scale for tighter buttons) + float textScale = 1.2f; + int textW = 0, textH = 0; + font->measure(label, textScale, textW, textH); + float tx = x + (w - static_cast(textW)) * 0.5f; + // Adjust vertical position for better alignment with background buttons + // Vertically center text precisely within the button + // Vertically center text precisely within the button, then nudge down slightly + // to improve optical balance relative to icons and button art. + const float textNudge = 3.0f; // tweak this value to move labels up/down + float ty = y + (h - static_cast(textH)) * 0.5f + textNudge; + + // Choose text color based on selection state + SDL_Color textColor = {255, 255, 255, 255}; // Default white + if (isSelected) { + textColor = {255, 220, 0, 255}; // Yellow when selected + } + + // Text shadow + font->draw(renderer, tx + 2.0f, ty + 2.0f, label, textScale, {0, 0, 0, 200}); + // Text + font->draw(renderer, tx, ty, label, textScale, textColor); + } +} + +void UIRenderer::computeContentOffsets(float winW, float winH, float logicalW, float logicalH, float logicalScale, float& outOffsetX, float& outOffsetY) { + float contentW = logicalW * logicalScale; + float contentH = logicalH * logicalScale; + outOffsetX = (winW - contentW) * 0.5f / logicalScale; + outOffsetY = (winH - contentH) * 0.5f / logicalScale; +} + +void UIRenderer::drawLogo(SDL_Renderer* renderer, SDL_Texture* logoTex, float logicalW, float logicalH, float contentOffsetX, float contentOffsetY, int texW, int texH) { + if (!renderer || !logoTex) return; + + float w = 0.0f; + float h = 0.0f; + + if (texW > 0 && texH > 0) { + w = static_cast(texW); + h = static_cast(texH); + } else { + SDL_GetTextureSize(logoTex, &w, &h); + } + + if (w > 0.0f && h > 0.0f) { + float maxWidth = logicalW * 0.6f; + float scale = std::min(1.0f, maxWidth / w); + float dw = w * scale; + float dh = h * scale; + float logoX = (logicalW - dw) * 0.5f + contentOffsetX; + float logoY = logicalH * 0.05f + contentOffsetY; + SDL_FRect dst{logoX, logoY, dw, dh}; + SDL_RenderTexture(renderer, logoTex, nullptr, &dst); + } +} + +void UIRenderer::drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, float logicalW, float logicalH, bool musicEnabled, bool soundEnabled) { + if (!renderer || !font) return; + + float popupW = 350, popupH = 260; + float popupX = (logicalW - popupW) / 2; + float popupY = (logicalH - popupH) / 2; + + // Semi-transparent overlay + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 128); + SDL_FRect overlay{0, 0, logicalW, logicalH}; + SDL_RenderFillRect(renderer, &overlay); + + // Popup background + SDL_SetRenderDrawColor(renderer, 100, 120, 160, 255); + SDL_FRect bord{popupX-4, popupY-4, popupW+8, popupH+8}; + SDL_RenderFillRect(renderer, &bord); + + SDL_SetRenderDrawColor(renderer, 40, 50, 70, 255); + SDL_FRect body{popupX, popupY, popupW, popupH}; + SDL_RenderFillRect(renderer, &body); + + // Title + font->draw(renderer, popupX + 20, popupY + 20, "SETTINGS", 2.0f, {255, 220, 0, 255}); + + // Music toggle + font->draw(renderer, popupX + 20, popupY + 70, "MUSIC:", 1.5f, {255, 255, 255, 255}); + const char* musicStatus = musicEnabled ? "ON" : "OFF"; + SDL_Color musicColor = musicEnabled ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255}; + font->draw(renderer, popupX + 120, popupY + 70, musicStatus, 1.5f, musicColor); + + // Sound effects toggle + font->draw(renderer, popupX + 20, popupY + 100, "SOUND FX:", 1.5f, {255, 255, 255, 255}); + const char* soundStatus = soundEnabled ? "ON" : "OFF"; + SDL_Color soundColor = soundEnabled ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255}; + font->draw(renderer, popupX + 140, popupY + 100, soundStatus, 1.5f, soundColor); + + // Instructions + font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255}); + font->draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255}); + font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255}); +} diff --git a/src/graphics/renderers/UIRenderer.h b/src/graphics/renderers/UIRenderer.h new file mode 100644 index 0000000..3fff420 --- /dev/null +++ b/src/graphics/renderers/UIRenderer.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include + +class FontAtlas; + +class UIRenderer { +public: + // Draw a sci-fi style panel with glow, shadow, and border + static void drawSciFiPanel(SDL_Renderer* renderer, const SDL_FRect& rect, float alpha = 1.0f); + + // Draw a generic button with hover/select states + static void drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, float cy, float w, float h, + const std::string& label, bool isHovered, bool isSelected, + SDL_Color bgColor = {80, 110, 200, 255}, + SDL_Color borderColor = {60, 80, 140, 255}, + bool textOnly = false, + SDL_Texture* icon = nullptr); + + // Helper to calculate content offsets for centering + static void computeContentOffsets(float winW, float winH, float logicalW, float logicalH, float logicalScale, float& outOffsetX, float& outOffsetY); + + // Draw the game logo centered at the top + static void drawLogo(SDL_Renderer* renderer, SDL_Texture* logoTex, float logicalW, float logicalH, float contentOffsetX, float contentOffsetY, int texW = 0, int texH = 0); + + // Draw the settings popup + static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, float logicalW, float logicalH, bool musicEnabled, bool soundEnabled); +}; diff --git a/src/main.cpp b/src/main.cpp index b540136..2c7e876 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "persistence/Scores.h" #include "graphics/effects/Starfield.h" #include "graphics/effects/Starfield3D.h" +#include "graphics/effects/SpaceWarp.h" #include "graphics/ui/Font.h" #include "graphics/ui/HelpOverlay.h" #include "gameplay/effects/LineEffect.h" @@ -159,7 +160,7 @@ static bool queueLevelBackground(LevelBackgroundFader& fader, SDL_Renderer* rend } char bgPath[256]; - std::snprintf(bgPath, sizeof(bgPath), "assets/images/tetris_main_back_level%d.jpg", level); + std::snprintf(bgPath, sizeof(bgPath), "assets/images/levels/level%d.jpg", level); SDL_Texture* newTexture = loadTextureFromImage(renderer, bgPath); if (!newTexture) { @@ -246,6 +247,31 @@ static void renderScaledBackground(SDL_Renderer* renderer, SDL_Texture* tex, int SDL_SetTextureAlphaMod(tex, 255); } +static void renderDynamicBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float baseScale, float motionClockMs, float alphaMul = 1.0f) { + if (!renderer || !tex) { + return; + } + + const float seconds = motionClockMs * 0.001f; + const float wobble = std::max(0.4f, baseScale + std::sin(seconds * 0.07f) * 0.02f + std::sin(seconds * 0.23f) * 0.01f); + const float rotation = std::sin(seconds * 0.035f) * 1.25f; + const float panX = std::sin(seconds * 0.11f) * winW * 0.02f; + const float panY = std::cos(seconds * 0.09f) * winH * 0.015f; + + SDL_FRect dest{ + (winW - winW * wobble) * 0.5f + panX, + (winH - winH * wobble) * 0.5f + panY, + winW * wobble, + winH * wobble + }; + SDL_FPoint center{dest.w * 0.5f, dest.h * 0.5f}; + + Uint8 alpha = static_cast(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f); + SDL_SetTextureAlphaMod(tex, alpha); + SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, ¢er, SDL_FLIP_NONE); + SDL_SetTextureAlphaMod(tex, 255); +} + static void drawOverlay(SDL_Renderer* renderer, const SDL_FRect& rect, SDL_Color color, Uint8 alpha) { if (!renderer || alpha == 0) { return; @@ -256,7 +282,7 @@ static void drawOverlay(SDL_Renderer* renderer, const SDL_FRect& rect, SDL_Color SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } -static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Renderer* renderer, int winW, int winH) { +static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Renderer* renderer, int winW, int winH, float motionClockMs) { if (!renderer) { return; } @@ -264,12 +290,13 @@ static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Render SDL_FRect fullRect{0.f, 0.f, static_cast(winW), static_cast(winH)}; const float duration = std::max(1.0f, fader.phaseDurationMs); const float progress = (fader.phase == LevelBackgroundPhase::Idle) ? 0.0f : std::clamp(fader.phaseElapsedMs / duration, 0.0f, 1.0f); + const float seconds = motionClockMs * 0.001f; switch (fader.phase) { case LevelBackgroundPhase::ZoomOut: { const float scale = 1.0f + progress * 0.15f; if (fader.currentTex) { - renderScaledBackground(renderer, fader.currentTex, winW, winH, scale, Uint8((1.0f - progress * 0.4f) * 255.0f)); + renderDynamicBackground(renderer, fader.currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f)); drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, Uint8(progress * 200.0f)); } break; @@ -278,16 +305,18 @@ static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Render const float scale = 1.10f - progress * 0.10f; const Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f); if (fader.currentTex) { - renderScaledBackground(renderer, fader.currentTex, winW, winH, scale, alpha); + renderDynamicBackground(renderer, fader.currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f); } break; } case LevelBackgroundPhase::Idle: default: if (fader.currentTex) { - renderScaledBackground(renderer, fader.currentTex, winW, winH, 1.0f, 255); + renderDynamicBackground(renderer, fader.currentTex, winW, winH, 1.02f, motionClockMs, 1.0f); + float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f)); + drawOverlay(renderer, fullRect, SDL_Color{5, 12, 28, 255}, Uint8(pulse * 90.0f)); } else if (fader.nextTex) { - renderScaledBackground(renderer, fader.nextTex, winW, winH, 1.0f, 255); + renderDynamicBackground(renderer, fader.nextTex, winW, winH, 1.02f, motionClockMs, 1.0f); } else { drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, 255); } @@ -310,189 +339,7 @@ static void resetLevelBackgrounds(LevelBackgroundFader& fader) { // ...existing code... -// ----------------------------------------------------------------------------- -// Enhanced Button Drawing -// ----------------------------------------------------------------------------- -static void drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, bool isHovered, bool isSelected = false) { - SDL_Color bgColor = isHovered ? SDL_Color{120, 150, 240, 255} : SDL_Color{80, 110, 200, 255}; - if (isSelected) bgColor = {160, 190, 255, 255}; - - float x = cx - w/2; - float y = cy - h/2; - - // Draw button background with border - drawRect(renderer, x-2, y-2, w+4, h+4, {60, 80, 140, 255}); // Border - drawRect(renderer, x, y, w, h, bgColor); // Background - - // Draw button text centered - float textScale = 1.5f; - float textX = x + (w - label.length() * 12 * textScale) / 2; - float textY = y + (h - 20 * textScale) / 2; - font.draw(renderer, textX, textY, label, textScale, {255, 255, 255, 255}); -} - -// External wrapper for enhanced button so other translation units can call it. -void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, bool isHovered, bool isSelected) { - drawEnhancedButton(renderer, font, cx, cy, w, h, label, isHovered, isSelected); -} - -// Popup wrappers -// Forward declarations for popup functions defined later in this file -static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled); - -void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled) { - drawSettingsPopup(renderer, font, musicEnabled); -} - -// Simple rounded menu button drawer used by MenuState (keeps visual parity with JS) -void menu_drawMenuButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, SDL_Color bgColor, SDL_Color borderColor) { - float x = cx - w/2; - float y = cy - h/2; - drawRect(renderer, x-6, y-6, w+12, h+12, borderColor); - drawRect(renderer, x-4, y-4, w+8, h+8, {255,255,255,255}); - drawRect(renderer, x, y, w, h, bgColor); - - float textScale = 1.6f; - float approxCharW = 12.0f * textScale; - float textW = label.length() * approxCharW; - float tx = x + (w - textW) / 2.0f; - float ty = y + (h - 20.0f * textScale) / 2.0f; - font.draw(renderer, tx+2, ty+2, label, textScale, {0,0,0,180}); - font.draw(renderer, tx, ty, label, textScale, {255,255,255,255}); -} - -// ----------------------------------------------------------------------------- -// Block Drawing Functions -// ----------------------------------------------------------------------------- -static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) { - if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) { - // Debug: print why we're falling back - if (!blocksTex) { - static bool printed = false; - if (!printed) { - (void)0; - printed = true; - } - } - // Fallback to colored rectangle if texture isn't available - SDL_Color color = (blockType >= 0 && blockType < PIECE_COUNT) ? COLORS[blockType + 1] : SDL_Color{128, 128, 128, 255}; - drawRect(renderer, x, y, size-1, size-1, color); - return; - } - - // JavaScript uses: sx = type * spriteSize, sy = 0, with 2px padding - // Each sprite is 90px wide in the horizontal sprite sheet - const int SPRITE_SIZE = 90; - float srcX = blockType * SPRITE_SIZE + 2; // Add 2px padding like JS - float srcY = 2; // Add 2px padding from top like JS - float srcW = SPRITE_SIZE - 4; // Subtract 4px total padding like JS - float srcH = SPRITE_SIZE - 4; // Subtract 4px total padding like JS - - SDL_FRect srcRect = {srcX, srcY, srcW, srcH}; - SDL_FRect dstRect = {x, y, size, size}; - SDL_RenderTexture(renderer, blocksTex, &srcRect, &dstRect); -} - -static void drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost = false) { - if (piece.type >= PIECE_COUNT) return; - - for (int cy = 0; cy < 4; ++cy) { - for (int cx = 0; cx < 4; ++cx) { - if (Game::cellFilled(piece, cx, cy)) { - float px = ox + (piece.x + cx) * tileSize; - float py = oy + (piece.y + cy) * tileSize; - - if (isGhost) { - - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); - - // Draw ghost piece as barely visible gray outline - SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20); // Very faint gray - SDL_FRect rect = {px + 2, py + 2, tileSize - 4, tileSize - 4}; - SDL_RenderFillRect(renderer, &rect); - - // Draw thin gray border - SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30); - SDL_FRect border = {px + 1, py + 1, tileSize - 2, tileSize - 2}; - SDL_RenderRect(renderer, &border); - } else { - drawBlockTexture(renderer, blocksTex, px, py, tileSize, piece.type); - } - } - } - } -} - -static void drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) { - if (pieceType >= PIECE_COUNT) return; - - // Use the first rotation (index 0) for preview - Game::Piece previewPiece; - previewPiece.type = pieceType; - previewPiece.rot = 0; - previewPiece.x = 0; - previewPiece.y = 0; - - // Center the piece in the preview area - float offsetX = 0, offsetY = 0; - if (pieceType == I) { offsetX = tileSize * 0.5f; } // I-piece centering - else if (pieceType == O) { offsetX = tileSize * 0.5f; } // O-piece centering - - // Use semi-transparent alpha for preview blocks - Uint8 previewAlpha = 180; // Change this value for more/less transparency - SDL_SetTextureAlphaMod(blocksTex, previewAlpha); - for (int cy = 0; cy < 4; ++cy) { - for (int cx = 0; cx < 4; ++cx) { - if (Game::cellFilled(previewPiece, cx, cy)) { - float px = x + offsetX + cx * tileSize; - float py = y + offsetY + cy * tileSize; - drawBlockTexture(renderer, blocksTex, px, py, tileSize, pieceType); - } - } - } - SDL_SetTextureAlphaMod(blocksTex, 255); // Reset alpha after drawing -} - -// ----------------------------------------------------------------------------- -// Popup Drawing Functions -// ----------------------------------------------------------------------------- -static void drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled) { - float popupW = 350, popupH = 260; - float popupX = (LOGICAL_W - popupW) / 2; - float popupY = (LOGICAL_H - popupH) / 2; - - // Semi-transparent overlay - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 128); - SDL_FRect overlay{0, 0, LOGICAL_W, LOGICAL_H}; - SDL_RenderFillRect(renderer, &overlay); - - // Popup background - drawRect(renderer, popupX-4, popupY-4, popupW+8, popupH+8, {100, 120, 160, 255}); // Border - drawRect(renderer, popupX, popupY, popupW, popupH, {40, 50, 70, 255}); // Background - - // Title - font.draw(renderer, popupX + 20, popupY + 20, "SETTINGS", 2.0f, {255, 220, 0, 255}); - - // Music toggle - font.draw(renderer, popupX + 20, popupY + 70, "MUSIC:", 1.5f, {255, 255, 255, 255}); - const char* musicStatus = musicEnabled ? "ON" : "OFF"; - SDL_Color musicColor = musicEnabled ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255}; - font.draw(renderer, popupX + 120, popupY + 70, musicStatus, 1.5f, musicColor); - - // Sound effects toggle - font.draw(renderer, popupX + 20, popupY + 100, "SOUND FX:", 1.5f, {255, 255, 255, 255}); - const char* soundStatus = SoundEffectManager::instance().isEnabled() ? "ON" : "OFF"; - SDL_Color soundColor = SoundEffectManager::instance().isEnabled() ? SDL_Color{0, 255, 0, 255} : SDL_Color{255, 0, 0, 255}; - font.draw(renderer, popupX + 140, popupY + 100, soundStatus, 1.5f, soundColor); - - // Instructions - font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255}); - font.draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255}); - font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255}); -} +// Legacy rendering functions removed (moved to UIRenderer / GameRenderer) // ----------------------------------------------------------------------------- @@ -520,7 +367,9 @@ static bool helpOverlayPausedGame = false; // ----------------------------------------------------------------------------- // Tetris Block Fireworks for intro animation (block particles) // Forward declare block render helper used by particles -static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType); +// Forward declare block render helper used by particles +// (Note: drawBlockTexture implementation was removed, so this is likely dead code unless particles use it. +// However, particles use drawFireworks_impl which uses SDL_RenderGeometry, so this is unused.) // ----------------------------------------------------------------------------- struct BlockParticle { float x{}, y{}; @@ -680,16 +529,15 @@ static void drawFireworks_impl(SDL_Renderer* renderer, SDL_Texture*) { SDL_SetRenderDrawBlendMode(renderer, previousBlend); } -// External wrappers for use by other translation units (MenuState) -// Expect callers to pass the blocks texture via StateContext so we avoid globals. -void menu_drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) { drawFireworks_impl(renderer, blocksTex); } -void menu_updateFireworks(double frameMs) { updateFireworks(frameMs); } +// External wrappers retained for compatibility; now no-ops to disable the legacy fireworks effect. +void menu_drawFireworks(SDL_Renderer*, SDL_Texture*) {} +void menu_updateFireworks(double) {} double menu_getLogoAnimCounter() { return logoAnimCounter; } int menu_getHoveredButton() { return hoveredButton; } int main(int, char **) { - // Initialize random seed for fireworks + // Initialize random seed for procedural effects srand(static_cast(SDL_GetTicks())); // Load settings @@ -760,12 +608,13 @@ int main(int, char **) SDL_GetError()); } - FontAtlas font; - font.init("FreeSans.ttf", 24); - - // Load PressStart2P font for loading screen and retro UI elements + // Primary UI font (Orbitron) used for major UI text: buttons, loading, HUD FontAtlas pixelFont; - pixelFont.init("assets/fonts/PressStart2P-Regular.ttf", 16); + pixelFont.init("assets/fonts/Orbitron.ttf", 22); + + // Secondary font (Exo2) used for longer descriptions, settings, credits + FontAtlas font; + font.init("assets/fonts/Exo2.ttf", 20); ScoreManager scores; std::atomic scoresLoadComplete{false}; @@ -779,21 +628,48 @@ int main(int, char **) starfield.init(200, LOGICAL_W, LOGICAL_H); Starfield3D starfield3D; starfield3D.init(LOGICAL_W, LOGICAL_H, 200); + SpaceWarp spaceWarp; + spaceWarp.init(LOGICAL_W, LOGICAL_H, 420); + SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward; + spaceWarp.setFlightMode(warpFlightMode); + bool warpAutoPilotEnabled = true; + spaceWarp.setAutoPilotEnabled(true); // Initialize line clearing effects LineEffect lineEffect; lineEffect.init(renderer); // Load logo assets via SDL_image so we can use compressed formats - SDL_Texture* logoTex = loadTextureFromImage(renderer, "assets/images/logo.bmp"); + SDL_Texture* logoTex = loadTextureFromImage(renderer, "assets/images/spacetris.png"); // Load small logo (used by Menu to show whole logo) int logoSmallW = 0, logoSmallH = 0; - SDL_Texture* logoSmallTex = loadTextureFromImage(renderer, "assets/images/logo_small.bmp", &logoSmallW, &logoSmallH); + SDL_Texture* logoSmallTex = loadTextureFromImage(renderer, "assets/images/spacetris.png", &logoSmallW, &logoSmallH); // Load menu background using SDL_image (prefers JPEG) SDL_Texture* backgroundTex = loadTextureFromImage(renderer, "assets/images/main_background.bmp"); + // Load the new main screen overlay that sits above the background but below buttons + int mainScreenW = 0; + int mainScreenH = 0; + SDL_Texture* mainScreenTex = loadTextureFromImage(renderer, "assets/images/main_screen.png", &mainScreenW, &mainScreenH); + if (mainScreenTex) { + SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded main_screen overlay %dx%d (tex=%p)", mainScreenW, mainScreenH, (void*)mainScreenTex); + FILE* f = fopen("tetris_trace.log", "a"); + if (f) { + fprintf(f, "main.cpp: loaded main_screen.bmp %dx%d tex=%p\n", mainScreenW, mainScreenH, (void*)mainScreenTex); + fclose(f); + } + } else { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Failed to load assets/images/main_screen.bmp (overlay will be skipped)"); + FILE* f = fopen("tetris_trace.log", "a"); + if (f) { + fprintf(f, "main.cpp: failed to load main_screen.bmp\n"); + fclose(f); + } + } + // Note: `backgroundTex` is owned by main and passed into `StateContext::backgroundTex` below. // States should render using `ctx.backgroundTex` rather than accessing globals. @@ -825,6 +701,19 @@ int main(int, char **) SDL_SetRenderTarget(renderer, nullptr); } + + SDL_Texture* scorePanelTex = loadTextureFromImage(renderer, "assets/images/panel_score.png"); + if (scorePanelTex) { + SDL_SetTextureBlendMode(scorePanelTex, SDL_BLENDMODE_BLEND); + } + SDL_Texture* statisticsPanelTex = loadTextureFromImage(renderer, "assets/images/statistics_panel.png"); + if (statisticsPanelTex) { + SDL_SetTextureBlendMode(statisticsPanelTex, SDL_BLENDMODE_BLEND); + } + SDL_Texture* nextPanelTex = loadTextureFromImage(renderer, "assets/images/next_panel.png"); + if (nextPanelTex) { + SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND); + } Game game(startLevelSelection); // Apply global gravity speed multiplier from config @@ -950,6 +839,7 @@ int main(int, char **) int gameplayCountdownIndex = 0; const double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; const std::array GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; + double gameplayBackgroundClockMs = 0.0; // Instantiate state manager StateManager stateMgr(state); @@ -971,6 +861,12 @@ int main(int, char **) ctx.logoSmallH = logoSmallH; ctx.backgroundTex = backgroundTex; ctx.blocksTex = blocksTex; + ctx.scorePanelTex = scorePanelTex; + ctx.statisticsPanelTex = statisticsPanelTex; + ctx.nextPanelTex = nextPanelTex; + ctx.mainScreenTex = mainScreenTex; + ctx.mainScreenW = mainScreenW; + ctx.mainScreenH = mainScreenH; ctx.musicEnabled = &musicEnabled; ctx.startLevelSelection = &startLevelSelection; ctx.hoveredButton = &hoveredButton; @@ -1168,6 +1064,40 @@ int main(int, char **) SDL_SetWindowFullscreen(window, isFullscreen ? SDL_WINDOW_FULLSCREEN : 0); Settings::instance().setFullscreen(isFullscreen); } + if (e.key.scancode == SDL_SCANCODE_F5) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::Forward; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: forward"); + } + if (e.key.scancode == SDL_SCANCODE_F6) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::BankLeft; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank left"); + } + if (e.key.scancode == SDL_SCANCODE_F7) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::BankRight; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank right"); + } + if (e.key.scancode == SDL_SCANCODE_F8) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::Reverse; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: reverse"); + } + if (e.key.scancode == SDL_SCANCODE_F9) + { + warpAutoPilotEnabled = true; + spaceWarp.setAutoPilotEnabled(true); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp autopilot engaged"); + } } // Text input for high score @@ -1377,6 +1307,7 @@ int main(int, char **) // Cap frame time to avoid spiral of death (max 100ms) if (frameMs > 100.0) frameMs = 100.0; + gameplayBackgroundClockMs += frameMs; const bool *ks = SDL_GetKeyboardState(nullptr); bool left = state == AppState::Playing && ks[SDL_SCANCODE_LEFT]; bool right = state == AppState::Playing && ks[SDL_SCANCODE_RIGHT]; @@ -1565,21 +1496,25 @@ int main(int, char **) } previousState = state; - // Update starfields based on current state + // Update background effects if (state == AppState::Loading) { starfield3D.update(float(frameMs / 1000.0f)); - starfield3D.resize(logicalVP.w, logicalVP.h); // Update for window resize + starfield3D.resize(winW, winH); } else { starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h); } + if (state == AppState::Menu) { + spaceWarp.resize(winW, winH); + spaceWarp.update(float(frameMs / 1000.0f)); + } + // Advance level background fade if a next texture is queued updateLevelBackgroundFade(levelBackgrounds, float(frameMs)); // Update intro animations if (state == AppState::Menu) { logoAnimCounter += frameMs * 0.0008; // Animation speed - updateFireworks(frameMs); } // --- Per-state update hooks (allow states to manage logic incrementally) @@ -1671,19 +1606,23 @@ int main(int, char **) // --- Render --- SDL_SetRenderViewport(renderer, nullptr); - SDL_SetRenderDrawColor(renderer, 12, 12, 16, 255); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_RenderClear(renderer); // Draw level-based background for gameplay, starfield for other states if (state == AppState::Playing) { int bgLevel = std::clamp(game.level(), 0, 32); queueLevelBackground(levelBackgrounds, renderer, bgLevel); - renderLevelBackgrounds(levelBackgrounds, renderer, winW, winH); + renderLevelBackgrounds(levelBackgrounds, renderer, winW, winH, static_cast(gameplayBackgroundClockMs)); } else if (state == AppState::Loading) { // Use 3D starfield for loading screen (full screen) starfield3D.draw(renderer); - } else if (state == AppState::Menu || state == AppState::LevelSelector || state == AppState::Options) { - // Use static background for menu, stretched to window; no starfield on sides + } else if (state == AppState::Menu) { + // Space flyover backdrop for the main screen + spaceWarp.draw(renderer, 1.0f); + // `mainScreenTex` is rendered as a top layer just before presenting + // so we don't draw it here. Keep the space warp background only. + } else if (state == AppState::LevelSelector || state == AppState::Options) { if (backgroundTex) { SDL_FRect fullRect = { 0, 0, (float)winW, (float)winH }; SDL_RenderTexture(renderer, backgroundTex, nullptr, &fullRect); @@ -1824,6 +1763,9 @@ int main(int, char **) &pixelFont, &lineEffect, blocksTex, + ctx.statisticsPanelTex, + scorePanelTex, + nextPanelTex, (float)LOGICAL_W, (float)LOGICAL_H, logicalScale, @@ -2014,6 +1956,43 @@ int main(int, char **) HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY); } + // Top-layer overlay: render `mainScreenTex` above all other layers when in Menu + if (state == AppState::Menu && mainScreenTex) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.f, 1.f); + float texW = mainScreenW > 0 ? static_cast(mainScreenW) : 0.0f; + float texH = mainScreenH > 0 ? static_cast(mainScreenH) : 0.0f; + if (texW <= 0.0f || texH <= 0.0f) { + float iwf = 0.0f, ihf = 0.0f; + if (SDL_GetTextureSize(mainScreenTex, &iwf, &ihf) != 0) { + iwf = ihf = 0.0f; + } + texW = iwf; + texH = ihf; + } + if (texW > 0.0f && texH > 0.0f) { + const float drawH = static_cast(winH); + const float scale = drawH / texH; + const float drawW = texW * scale; + SDL_FRect dst{ + (winW - drawW) * 0.5f, + 0.0f, + drawW, + drawH + }; + SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND); + SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst); + } + // Restore logical viewport/scale and draw the main PLAY button above the overlay + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + if (menuState) { + menuState->drawMainButtonNormally = false; // ensure it isn't double-drawn + menuState->renderMainButtonTop(renderer, logicalScale, logicalVP); + menuState->drawMainButtonNormally = true; + } + } + SDL_RenderPresent(renderer); SDL_SetRenderScale(renderer, 1.f, 1.f); } @@ -2021,9 +2000,13 @@ int main(int, char **) SDL_DestroyTexture(logoTex); if (backgroundTex) SDL_DestroyTexture(backgroundTex); + if (mainScreenTex) + SDL_DestroyTexture(mainScreenTex); resetLevelBackgrounds(levelBackgrounds); if (blocksTex) SDL_DestroyTexture(blocksTex); + if (scorePanelTex) + SDL_DestroyTexture(scorePanelTex); if (logoSmallTex) SDL_DestroyTexture(logoSmallTex); diff --git a/src/states/LevelSelectorState.cpp b/src/states/LevelSelectorState.cpp index 5996c26..861240b 100644 --- a/src/states/LevelSelectorState.cpp +++ b/src/states/LevelSelectorState.cpp @@ -8,6 +8,7 @@ #include #include #include +#include "../graphics/renderers/UIRenderer.h" // Use dynamic logical dimensions from GlobalState instead of hardcoded values @@ -93,30 +94,7 @@ static void Vignette(SDL_Renderer* r, int w, int h) { FillRect(r, SDL_FRect{(float)w - pad, 0, (float)pad, (float)h}, SDL_Color{0, 0, 0, 140}); } -static SDL_FRect DrawPanel(SDL_Renderer* r, float w, float h, bool draw = true, float offX = 0.f, float offY = 0.f) { - float PW = std::min(520.f, w * 0.65f); - float PH = std::min(360.f, h * 0.7f); - SDL_FRect p{ (w - PW) / 2.f + offX, (h - PH) / 2.f - 40.f + offY, PW, PH }; // Moved up by 50px - - if (!draw) return p; // geometry only - - // drop shadow - FillRect(r, SDL_FRect{p.x + 6, p.y + 10, p.w, p.h}, SDL_Color{0, 0, 0, 120}); - // glow aura - for (int i = 0; i < 6; i++) { - SDL_FRect g{ p.x - (float)(i * 2), p.y - (float)(i * 2), p.w + (float)(i * 4), p.h + (float)(i * 4) }; - SDL_Color c = COL_CYAN_SO; c.a = (Uint8)(36 - i * 6); - StrokeRect(r, g, c); - } - // outer body + border - FillRect(r, p, COL_PANEL); - StrokeRect(r, p, COL_CYAN); - - // inner face - FillRect(r, SDL_FRect{p.x + 12, p.y + 56, p.w - 24, p.h - 68}, COL_PANEL_IN); - StrokeRect(r, SDL_FRect{p.x + 12, p.y + 56, p.w - 24, p.h - 68}, SDL_Color{24, 31, 41, 180}); - return p; -} +// DrawPanel removed, replaced by UIRenderer::drawSciFiPanel struct Grid { int cols = 4, rows = 5; @@ -195,7 +173,17 @@ void LevelSelectorState::handleEvent(const SDL_Event& e) { float ly = (float(e.button.y) - float(lastLogicalVP.y)) / (lastLogicalScale > 0.f ? lastLogicalScale : 1.f); // Use same panel calculation as render (centered) - SDL_FRect panel = DrawPanel(nullptr, LOGICAL_W, LOGICAL_H, /*draw=*/false, 0.f, 0.f); + const float LOGICAL_W_F = 1200.f; + const float LOGICAL_H_F = 1000.f; + float winW = (float)lastLogicalVP.w; + float winH = (float)lastLogicalVP.h; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W_F, LOGICAL_H_F, lastLogicalScale, contentOffsetX, contentOffsetY); + + float PW = std::min(520.f, LOGICAL_W_F * 0.65f); + float PH = std::min(360.f, LOGICAL_H_F * 0.7f); + SDL_FRect panel{ (LOGICAL_W_F - PW) / 2.f + contentOffsetX, (LOGICAL_H_F - PH) / 2.f - 40.f + contentOffsetY, PW, PH }; Grid g = MakeGrid(panel); int hit = HitTest(g, int(lx), int(ly)); if (hit != -1) { @@ -214,7 +202,17 @@ void LevelSelectorState::handleEvent(const SDL_Event& e) { float ly = (float(e.motion.y) - float(lastLogicalVP.y)) / (lastLogicalScale > 0.f ? lastLogicalScale : 1.f); // Use same panel calculation as render (centered) - SDL_FRect panel = DrawPanel(nullptr, LOGICAL_W, LOGICAL_H, /*draw=*/false, 0.f, 0.f); + const float LOGICAL_W_F = 1200.f; + const float LOGICAL_H_F = 1000.f; + float winW = (float)lastLogicalVP.w; + float winH = (float)lastLogicalVP.h; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W_F, LOGICAL_H_F, lastLogicalScale, contentOffsetX, contentOffsetY); + + float PW = std::min(520.f, LOGICAL_W_F * 0.65f); + float PH = std::min(360.f, LOGICAL_H_F * 0.7f); + SDL_FRect panel{ (LOGICAL_W_F - PW) / 2.f + contentOffsetX, (LOGICAL_H_F - PH) / 2.f - 40.f + contentOffsetY, PW, PH }; Grid g = MakeGrid(panel); hoveredLevel = HitTest(g, int(lx), int(ly)); } @@ -242,29 +240,30 @@ void LevelSelectorState::drawLevelSelectionPopup(SDL_Renderer* renderer, float l // Compute content offsets (same approach as MenuState for proper centering) float winW = (float)logicalVP.w; float winH = (float)logicalVP.h; - float contentW = LOGICAL_W * logicalScale; - float contentH = LOGICAL_H * logicalScale; - float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; - float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W, LOGICAL_H, logicalScale, contentOffsetX, contentOffsetY); // Draw the logo at the top (same as MenuState) SDL_Texture* logoToUse = ctx.logoSmallTex ? ctx.logoSmallTex : ctx.logoTex; - if (logoToUse) { - // Use dimensions provided by the shared context when available - int texW = (logoToUse == ctx.logoSmallTex && ctx.logoSmallW > 0) ? ctx.logoSmallW : 872; - int texH = (logoToUse == ctx.logoSmallTex && ctx.logoSmallH > 0) ? ctx.logoSmallH : 273; - float maxW = LOGICAL_W * 0.6f; // Match MenuState and OptionsState - float scale = std::min(1.0f, maxW / float(texW)); - float dw = texW * scale; - float dh = texH * scale; - float logoX = (LOGICAL_W - dw) / 2.f + contentOffsetX; - float logoY = LOGICAL_H * 0.05f + contentOffsetY; // Match MenuState and OptionsState - SDL_FRect dst{logoX, logoY, dw, dh}; - SDL_RenderTexture(renderer, logoToUse, nullptr, &dst); + int logoW = 0, logoH = 0; + if (logoToUse == ctx.logoSmallTex && ctx.logoSmallW > 0) { + logoW = ctx.logoSmallW; + logoH = ctx.logoSmallH; } + UIRenderer::drawLogo(renderer, logoToUse, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY, logoW, logoH); // Panel and title strip (in logical space) - centered properly with offsets - SDL_FRect panel = DrawPanel(renderer, LOGICAL_W, LOGICAL_H, /*draw=*/true, contentOffsetX, contentOffsetY); + float PW = std::min(520.f, LOGICAL_W * 0.65f); + float PH = std::min(360.f, LOGICAL_H * 0.7f); + SDL_FRect panel{ (LOGICAL_W - PW) / 2.f + contentOffsetX, (LOGICAL_H - PH) / 2.f - 40.f + contentOffsetY, PW, PH }; + + UIRenderer::drawSciFiPanel(renderer, panel); + + // Inner face (LevelSelector specific) + SDL_FRect inner{panel.x + 12, panel.y + 56, panel.w - 24, panel.h - 68}; + FillRect(renderer, inner, COL_PANEL_IN); + StrokeRect(renderer, inner, SDL_Color{24, 31, 41, 180}); // Title text - prefer pixelFont for a blocky title if available, fallback to regular font FontAtlas* titleFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; @@ -296,7 +295,17 @@ bool LevelSelectorState::isMouseInPopup(float mouseX, float mouseY, float& popup lx = (float(mouseX) - float(lastLogicalVP.x)) / lastLogicalScale; ly = (float(mouseY) - float(lastLogicalVP.y)) / lastLogicalScale; } - SDL_FRect p = DrawPanel(nullptr, LOGICAL_W, LOGICAL_H, /*draw=*/false, 0.f, 0.f); + const float LOGICAL_W_F = 1200.f; + const float LOGICAL_H_F = 1000.f; + float winW = (float)lastLogicalVP.w; + float winH = (float)lastLogicalVP.h; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W_F, LOGICAL_H_F, lastLogicalScale, contentOffsetX, contentOffsetY); + + float PW = std::min(520.f, LOGICAL_W_F * 0.65f); + float PH = std::min(360.f, LOGICAL_H_F * 0.7f); + SDL_FRect p{ (LOGICAL_W_F - PW) / 2.f + contentOffsetX, (LOGICAL_H_F - PH) / 2.f - 40.f + contentOffsetY, PW, PH }; popupX = p.x; popupY = p.y; popupW = p.w; popupH = p.h; return lx >= popupX && lx <= popupX + popupW && ly >= popupY && ly <= popupY + popupH; } @@ -312,7 +321,17 @@ int LevelSelectorState::getLevelFromMouse(float mouseX, float mouseY, float popu lx = (float(mouseX) - float(lastLogicalVP.x)) / lastLogicalScale; ly = (float(mouseY) - float(lastLogicalVP.y)) / lastLogicalScale; } - SDL_FRect p = DrawPanel(nullptr, LOGICAL_W, LOGICAL_H, /*draw=*/false, 0.f, 0.f); + const float LOGICAL_W_F = 1200.f; + const float LOGICAL_H_F = 1000.f; + float winW = (float)lastLogicalVP.w; + float winH = (float)lastLogicalVP.h; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W_F, LOGICAL_H_F, lastLogicalScale, contentOffsetX, contentOffsetY); + + float PW = std::min(520.f, LOGICAL_W_F * 0.65f); + float PH = std::min(360.f, LOGICAL_H_F * 0.7f); + SDL_FRect p{ (LOGICAL_W_F - PW) / 2.f + contentOffsetX, (LOGICAL_H_F - PH) / 2.f - 40.f + contentOffsetY, PW, PH }; Grid g = MakeGrid(p); return HitTest(g, (int)lx, (int)ly); } diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 11a6adc..638bce1 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -2,8 +2,10 @@ #include "persistence/Scores.h" #include "graphics/Font.h" #include "../core/GlobalState.h" +#include "../core/Settings.h" #include "../core/state/StateManager.h" #include "../audio/Audio.h" +#include "../audio/SoundEffect.h" #include #include #include @@ -19,6 +21,88 @@ // `ctx.musicEnabled` and `ctx.hoveredButton` instead to avoid globals. // Menu helper wrappers are declared in a shared header implemented in main.cpp #include "../audio/MenuWrappers.h" +#include "../utils/ImagePathResolver.h" +#include "../graphics/renderers/UIRenderer.h" +#include "../graphics/renderers/GameRenderer.h" +#include + +// Frosted tint helper: draw a safe, inexpensive frosted overlay for the panel area. +// This avoids renderer readback / surface APIs which aren't portable across SDL3 builds. +static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP, float logicalScale, float panelTop, float panelH, SDL_Texture* sceneTex, int sceneW, int sceneH) { + if (!renderer) return; + // Preserve previous draw blend mode so callers don't get surprised when + // the helper early-returns or changes blend state. + SDL_BlendMode prevBlendMode = SDL_BLENDMODE_NONE; + SDL_GetRenderDrawBlendMode(renderer, &prevBlendMode); + // If we don't have a captured scene texture, fall back to the frosted tint. + if (!sceneTex || sceneW <= 0 || sceneH <= 0) { + float viewportLogicalW = (float)logicalVP.w / logicalScale; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 200, 210, 220, 48); + SDL_FRect base{ 0.0f, panelTop, viewportLogicalW, panelH }; + SDL_RenderFillRect(renderer, &base); + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 24); + SDL_FRect highlight{ 0.0f, panelTop, viewportLogicalW, std::max(2.0f, panelH * 0.06f) }; + SDL_RenderFillRect(renderer, &highlight); + SDL_SetRenderDrawColor(renderer, 16, 24, 32, 12); + SDL_FRect shadow{ 0.0f, panelTop + panelH - std::max(2.0f, panelH * 0.06f), viewportLogicalW, std::max(2.0f, panelH * 0.06f) }; + SDL_RenderFillRect(renderer, &shadow); + // Restore previous blend mode + SDL_SetRenderDrawBlendMode(renderer, prevBlendMode); + return; + } + + // Compute source rect in scene texture pixels for the panel area + int panelWinX = 0; + int panelWinY = static_cast(std::floor(panelTop * logicalScale + logicalVP.y)); + int panelWinW = sceneW; // full width of captured scene + int panelWinH = static_cast(std::ceil(panelH * logicalScale)); + if (panelWinW <= 0 || panelWinH <= 0) return; + + // Downsample size (cheap Gaussian-ish blur via scale). + int blurW = std::max(8, panelWinW / 6); + int blurH = std::max(4, panelWinH / 6); + + // Create a small render target to draw the downsampled region into + SDL_Texture* small = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, blurW, blurH); + if (!small) { + // Fall back to tint if we can't allocate + float viewportLogicalW = (float)logicalVP.w / logicalScale; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 200, 210, 220, 48); + SDL_FRect base{ 0.0f, panelTop, viewportLogicalW, panelH }; + SDL_RenderFillRect(renderer, &base); + SDL_SetRenderDrawBlendMode(renderer, prevBlendMode); + return; + } + + // Source rectangle in the scene texture (pixel coords) as floats + SDL_FRect srcRectF{ (float)panelWinX, (float)panelWinY, (float)panelWinW, (float)panelWinH }; + + // Render the sub-region of the scene into the small texture + SDL_SetRenderTarget(renderer, small); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + SDL_FRect smallDst{ 0.0f, 0.0f, (float)blurW, (float)blurH }; + SDL_RenderTexture(renderer, sceneTex, &srcRectF, &smallDst); + + // Reset target + SDL_SetRenderTarget(renderer, nullptr); + + // Render the small texture scaled up to the panel area with linear filtering + SDL_SetTextureBlendMode(small, SDL_BLENDMODE_BLEND); + SDL_SetTextureScaleMode(small, SDL_SCALEMODE_LINEAR); + + float viewportLogicalW = (float)logicalVP.w / logicalScale; + SDL_FRect dst{ 0.0f, panelTop, viewportLogicalW, panelH }; + SDL_RenderTexture(renderer, small, nullptr, &dst); + + // Cleanup + SDL_DestroyTexture(small); + // Restore previous blend mode so caller drawing is unaffected + SDL_SetRenderDrawBlendMode(renderer, prevBlendMode); +} MenuState::MenuState(StateContext& ctx) : State(ctx) {} @@ -32,10 +116,117 @@ void MenuState::onEnter() { } } +void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { + const float LOGICAL_W = 1200.f; + const float LOGICAL_H = 1000.f; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets((float)logicalVP.w, (float)logicalVP.h, LOGICAL_W, LOGICAL_H, logicalScale, contentOffsetX, contentOffsetY); + + float contentW = LOGICAL_W * logicalScale; + bool isSmall = (contentW < 700.0f); + float btnW = 200.0f; + float btnH = 70.0f; + float btnX = LOGICAL_W * 0.5f + contentOffsetX; + // move buttons a bit lower for better visibility + // small global vertical offset for the whole menu (tweak to move UI down) + float menuYOffset = LOGICAL_H * 0.03f; + float btnY = LOGICAL_H * 0.865f + contentOffsetY + (LOGICAL_H * 0.02f) + menuYOffset + 4.5f; + + // Compose same button definition used in render() + char levelBtnText[32]; + int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; + std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); + + struct MenuButtonDef { SDL_Color bg; SDL_Color border; std::string label; }; + std::array buttons = { + MenuButtonDef{ SDL_Color{60,180,80,255}, SDL_Color{30,120,40,255}, "PLAY" }, + MenuButtonDef{ SDL_Color{40,140,240,255}, SDL_Color{20,100,200,255}, levelBtnText }, + MenuButtonDef{ SDL_Color{130,80,210,255}, SDL_Color{90,40,170,255}, "OPTIONS" }, + MenuButtonDef{ SDL_Color{200,70,70,255}, SDL_Color{150,40,40,255}, "EXIT" } + }; + + std::array icons = { playIcon, levelIcon, optionsIcon, exitIcon }; + + float spacing = isSmall ? btnW * 1.2f : btnW * 1.15f; + + + + // Draw semi-transparent background panel behind the full button group (draw first so text sits on top) + // `groupCenterY` is declared here so it can be used when drawing the buttons below. + float groupCenterY = 0.0f; + { + float groupCenterX = btnX; + float halfSpan = 1.5f * spacing; // covers from leftmost to rightmost button centers + float panelLeft = groupCenterX - halfSpan - btnW * 0.5f - 14.0f; + float panelRight = groupCenterX + halfSpan + btnW * 0.5f + 14.0f; + // Nudge the panel slightly lower for better visual spacing + float panelTop = btnY - btnH * 0.5f - 12.0f + 18.0f; + float panelH = btnH + 24.0f; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + // Backdrop blur pass before tint (use captured scene texture if available) + renderBackdropBlur(renderer, logicalVP, logicalScale, panelTop, panelH, ctx.sceneTex, ctx.sceneW, ctx.sceneH); + // Brighter, more transparent background to increase contrast but keep scene visible + // More transparent background so underlying scene shows through + SDL_SetRenderDrawColor(renderer, 28, 36, 46, 110); + // Fill full-width background so edges are covered in fullscreen + float viewportLogicalW = (float)logicalVP.w / logicalScale; + SDL_FRect fullPanel{ 0.0f, panelTop, viewportLogicalW, panelH }; + SDL_RenderFillRect(renderer, &fullPanel); + // Also draw the central strip to keep visual center emphasis + SDL_FRect panelRect{ panelLeft, panelTop, panelRight - panelLeft, panelH }; + SDL_RenderFillRect(renderer, &panelRect); + // brighter full-width border (slightly more transparent) + SDL_SetRenderDrawColor(renderer, 120, 140, 160, 120); + // Expand border to cover full window width (use actual viewport) + SDL_FRect borderFull{ 0.0f, panelTop, viewportLogicalW, panelH }; + SDL_RenderRect(renderer, &borderFull); + // Compute a vertical center for the group so labels/icons can be centered + groupCenterY = panelTop + panelH * 0.5f; + } + + // Draw all four buttons on top + for (int i = 0; i < 4; ++i) { + float cxCenter = 0.0f; + // Use the group's center Y so text/icons sit visually centered in the panel + float cyCenter = groupCenterY; + if (ctx.menuButtonsExplicit) { + cxCenter = ctx.menuButtonCX[i] + contentOffsetX; + cyCenter = ctx.menuButtonCY[i] + contentOffsetY; + } else { + float offset = (static_cast(i) - 1.5f) * spacing; + // Apply small per-button offsets that match the original placements + float extra = 0.0f; + if (i == 0) extra = 15.0f; + if (i == 2) extra = -24.0f; + if (i == 3) extra = -44.0f; + cxCenter = btnX + offset + extra; + } + // Apply group alpha and transient flash to button colors + double aMul = std::clamp(buttonGroupAlpha + buttonFlash * buttonFlashAmount, 0.0, 1.0); + SDL_Color bgCol = buttons[i].bg; + SDL_Color bdCol = buttons[i].border; + bgCol.a = static_cast(std::round(aMul * static_cast(bgCol.a))); + bdCol.a = static_cast(std::round(aMul * static_cast(bdCol.a))); + UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, + buttons[i].label, false, selectedButton == i, + bgCol, bdCol, true, icons[i]); + // no per-button neon outline here; draw group background below instead + } + + // (panel for the top-button draw path is drawn before the buttons so text is on top) +} + void MenuState::onExit() { if (ctx.showExitConfirmPopup) { *ctx.showExitConfirmPopup = false; } + + // Clean up icon textures + if (playIcon) { SDL_DestroyTexture(playIcon); playIcon = nullptr; } + if (levelIcon) { SDL_DestroyTexture(levelIcon); levelIcon = nullptr; } + if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; } + if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; } } void MenuState::handleEvent(const SDL_Event& e) { @@ -66,47 +257,162 @@ void MenuState::handleEvent(const SDL_Event& e) { } }; - if (isExitPromptVisible()) { + // Inline exit HUD handling (replaces the old modal popup) + if (exitPanelVisible && !exitPanelAnimating) { switch (e.key.scancode) { case SDL_SCANCODE_LEFT: - case SDL_SCANCODE_UP: - setExitSelection(0); + // Move selection to YES + exitSelectedButton = 0; + if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; return; case SDL_SCANCODE_RIGHT: - case SDL_SCANCODE_DOWN: - setExitSelection(1); + // Move selection to NO + exitSelectedButton = 1; + if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; return; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: - if (getExitSelection() == 0) { - setExitPrompt(false); + if (exitSelectedButton == 0) { + // Confirm quit if (ctx.requestQuit) { ctx.requestQuit(); } else { - SDL_Event quit{}; - quit.type = SDL_EVENT_QUIT; - SDL_PushEvent(&quit); + SDL_Event quit{}; quit.type = SDL_EVENT_QUIT; SDL_PushEvent(&quit); } } else { - setExitPrompt(false); + // Close HUD + exitPanelAnimating = true; exitDirection = -1; + if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; } return; case SDL_SCANCODE_ESCAPE: - setExitPrompt(false); - setExitSelection(1); + // Close HUD + exitPanelAnimating = true; exitDirection = -1; + if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false; return; + case SDL_SCANCODE_PAGEDOWN: + case SDL_SCANCODE_DOWN: { + // scroll content down + exitScroll += 40.0; return; + } + case SDL_SCANCODE_PAGEUP: + case SDL_SCANCODE_UP: { + exitScroll -= 40.0; if (exitScroll < 0.0) exitScroll = 0.0; return; + } default: return; } } + // If the inline options HUD is visible and not animating, capture navigation + if (optionsVisible && !optionsAnimating) { + // Options rows drawn here are 5 (Fullscreen, Music, Sound FX, Smooth Scroll, Return) + constexpr int OPTIONS_ROW_COUNT = 5; + switch (e.key.scancode) { + case SDL_SCANCODE_UP: + { + optionsSelectedRow = (optionsSelectedRow + OPTIONS_ROW_COUNT - 1) % OPTIONS_ROW_COUNT; + return; + } + case SDL_SCANCODE_DOWN: + { + optionsSelectedRow = (optionsSelectedRow + 1) % OPTIONS_ROW_COUNT; + return; + } + case SDL_SCANCODE_LEFT: + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + { + // Perform toggle/action for the selected option + switch (optionsSelectedRow) { + case 0: { + // FULLSCREEN + bool nextState = ! (ctx.fullscreenFlag ? *ctx.fullscreenFlag : Settings::instance().isFullscreen()); + if (ctx.fullscreenFlag) *ctx.fullscreenFlag = nextState; + if (ctx.applyFullscreen) ctx.applyFullscreen(nextState); + Settings::instance().setFullscreen(nextState); + Settings::instance().save(); + return; + } + case 1: { + // MUSIC + bool next = !Settings::instance().isMusicEnabled(); + Settings::instance().setMusicEnabled(next); + Settings::instance().save(); + if (ctx.musicEnabled) *ctx.musicEnabled = next; + return; + } + case 2: { + // SOUND FX + bool next = !SoundEffectManager::instance().isEnabled(); + SoundEffectManager::instance().setEnabled(next); + Settings::instance().setSoundEnabled(next); + Settings::instance().save(); + return; + } + case 3: { + // SMOOTH SCROLL + bool next = !Settings::instance().isSmoothScrollEnabled(); + Settings::instance().setSmoothScrollEnabled(next); + Settings::instance().save(); + return; + } + case 4: { + // RETURN TO MENU -> hide panel + optionsAnimating = true; + optionsDirection = -1; + return; + } + } + } + default: + break; + } + } + + // If inline level HUD visible and not animating, capture navigation + if (levelPanelVisible && !levelPanelAnimating) { + // Start navigation from tentative hover if present, otherwise from committed selection + int c = (levelHovered >= 0) ? levelHovered : (levelSelected < 0 ? 0 : levelSelected); + switch (e.key.scancode) { + case SDL_SCANCODE_LEFT: if (c % 4 > 0) c--; break; + case SDL_SCANCODE_RIGHT: if (c % 4 < 3) c++; break; + case SDL_SCANCODE_UP: if (c / 4 > 0) c -= 4; break; + case SDL_SCANCODE_DOWN: if (c / 4 < 4) c += 4; break; + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + case SDL_SCANCODE_SPACE: + // Confirm tentative selection + levelSelected = c; + if (ctx.startLevelSelection) *ctx.startLevelSelection = levelSelected; + // close HUD + levelPanelAnimating = true; levelDirection = -1; + // clear hover candidate + levelHovered = -1; + return; + case SDL_SCANCODE_ESCAPE: + levelPanelAnimating = true; levelDirection = -1; + levelHovered = -1; + return; + default: break; + } + // Move tentative cursor (don't commit to startLevelSelection yet) + levelHovered = c; + // Consume the event so main menu navigation does not also run + return; + } + switch (e.key.scancode) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_UP: { const int total = 4; selectedButton = (selectedButton + total - 1) % total; + // brief bright flash on navigation + buttonFlash = 1.0; break; } case SDL_SCANCODE_RIGHT: @@ -114,6 +420,8 @@ void MenuState::handleEvent(const SDL_Event& e) { { const int total = 4; selectedButton = (selectedButton + 1) % total; + // brief bright flash on navigation + buttonFlash = 1.0; break; } case SDL_SCANCODE_RETURN: @@ -127,28 +435,54 @@ void MenuState::handleEvent(const SDL_Event& e) { triggerPlay(); break; case 1: - if (ctx.requestFadeTransition) { - ctx.requestFadeTransition(AppState::LevelSelector); - } else if (ctx.stateManager) { - ctx.stateManager->setState(AppState::LevelSelector); + // Toggle inline level selector HUD (show/hide) + if (!levelPanelVisible && !levelPanelAnimating) { + levelPanelAnimating = true; + levelDirection = 1; // show + // initialize tentative cursor to current selected level + levelHovered = levelSelected < 0 ? 0 : levelSelected; + } else if (levelPanelVisible && !levelPanelAnimating) { + levelPanelAnimating = true; + levelDirection = -1; // hide } break; case 2: - if (ctx.requestFadeTransition) { - ctx.requestFadeTransition(AppState::Options); - } else if (ctx.stateManager) { - ctx.stateManager->setState(AppState::Options); + // Toggle the options panel with an animated slide-in/out. + if (!optionsVisible && !optionsAnimating) { + optionsAnimating = true; + optionsDirection = 1; // show + } else if (optionsVisible && !optionsAnimating) { + optionsAnimating = true; + optionsDirection = -1; // hide } break; case 3: - setExitPrompt(true); - setExitSelection(1); + // Show the inline exit HUD + if (!exitPanelVisible && !exitPanelAnimating) { + exitPanelAnimating = true; + exitDirection = 1; + exitSelectedButton = 1; + if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = true; + if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; + } break; } break; case SDL_SCANCODE_ESCAPE: - setExitPrompt(true); - setExitSelection(1); + // If options panel is visible, hide it first. + if (optionsVisible && !optionsAnimating) { + optionsAnimating = true; + optionsDirection = -1; + return; + } + // Show inline exit HUD on ESC + if (!exitPanelVisible && !exitPanelAnimating) { + exitPanelAnimating = true; + exitDirection = 1; + exitSelectedButton = 1; + if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = true; + if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; + } break; default: break; @@ -159,9 +493,132 @@ void MenuState::handleEvent(const SDL_Event& e) { void MenuState::update(double frameMs) { // Update logo animation counter GlobalState::instance().logoAnimCounter += frameMs; - - // Update fireworks particles - GlobalState::instance().updateFireworks(frameMs); + // Advance options panel animation if active + if (optionsAnimating) { + double delta = (frameMs / optionsTransitionDurationMs) * static_cast(optionsDirection); + optionsTransition += delta; + if (optionsTransition >= 1.0) { + optionsTransition = 1.0; + optionsVisible = true; + optionsAnimating = false; + } else if (optionsTransition <= 0.0) { + optionsTransition = 0.0; + optionsVisible = false; + optionsAnimating = false; + } + } + + // Advance level panel animation if active + if (levelPanelAnimating) { + double delta = (frameMs / levelTransitionDurationMs) * static_cast(levelDirection); + levelTransition += delta; + if (levelTransition >= 1.0) { + levelTransition = 1.0; + levelPanelVisible = true; + levelPanelAnimating = false; + } else if (levelTransition <= 0.0) { + levelTransition = 0.0; + levelPanelVisible = false; + levelPanelAnimating = false; + } + } + + // Advance exit panel animation if active + if (exitPanelAnimating) { + double delta = (frameMs / exitTransitionDurationMs) * static_cast(exitDirection); + exitTransition += delta; + if (exitTransition >= 1.0) { + exitTransition = 1.0; + exitPanelVisible = true; + exitPanelAnimating = false; + } else if (exitTransition <= 0.0) { + exitTransition = 0.0; + exitPanelVisible = false; + exitPanelAnimating = false; + } + } + + // Animate level selection highlight position toward the selected cell center + if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) { + // Recompute same grid geometry used in render to find target center + const float LOGICAL_W = 1200.f; + const float LOGICAL_H = 1000.f; + float winW = (float)lastLogicalVP.w; + float winH = (float)lastLogicalVP.h; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W, LOGICAL_H, lastLogicalScale, contentOffsetX, contentOffsetY); + + const float PW = std::min(520.0f, LOGICAL_W * 0.65f); + const float PH = std::min(360.0f, LOGICAL_H * 0.7f); + float panelBaseX = (LOGICAL_W - PW) * 0.5f + contentOffsetX; + float panelBaseY = (LOGICAL_H - PH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); + float marginX = 34.0f, marginY = 56.0f; + SDL_FRect area{ panelBaseX + marginX, panelBaseY + marginY, PW - 2.0f * marginX, PH - marginY - 28.0f }; + const int cols = 4, rows = 5; + const float gapX = 12.0f, gapY = 12.0f; + float cellW = (area.w - (cols - 1) * gapX) / cols; + float cellH = (area.h - (rows - 1) * gapY) / rows; + + int targetIdx = std::clamp((levelHovered >= 0 ? levelHovered : levelSelected), 0, 19); + int tr = targetIdx / cols, tc = targetIdx % cols; + double targetX = area.x + tc * (cellW + gapX) + cellW * 0.5f; + double targetY = area.y + tr * (cellH + gapY) + cellH * 0.5f; + + if (!levelHighlightInitialized) { + levelHighlightX = targetX; + levelHighlightY = targetY; + levelHighlightInitialized = true; + } else { + // Exponential smoothing: alpha = 1 - exp(-k * dt) + double k = levelHighlightSpeed; // user-tunable speed constant + double alpha = 1.0 - std::exp(-k * frameMs); + if (alpha < 1e-6) alpha = std::min(1.0, frameMs * 0.02); + levelHighlightX += (targetX - levelHighlightX) * alpha; + levelHighlightY += (targetY - levelHighlightY) * alpha; + } + } + + // Update button group pulsing animation + if (buttonPulseEnabled) { + buttonPulseTime += frameMs; + double t = (buttonPulseTime * 0.001) * buttonPulseSpeed; // seconds * speed + double s = 0.0; + switch (buttonPulseEasing) { + case 0: // sin + s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5; + break; + case 1: // triangle + { + double ft = t - std::floor(t); + s = (ft < 0.5) ? (ft * 2.0) : (2.0 - ft * 2.0); + break; + } + case 2: // exponential ease-in-out (normalized) + { + double ft = t - std::floor(t); + if (ft < 0.5) { + s = 0.5 * std::pow(2.0, 20.0 * ft - 10.0); + } else { + s = 1.0 - 0.5 * std::pow(2.0, -20.0 * ft + 10.0); + } + // Clamp to 0..1 in case of numeric issues + if (s < 0.0) s = 0.0; if (s > 1.0) s = 1.0; + break; + } + default: + s = (std::sin(t * 2.0 * 3.14159265358979323846) * 0.5) + 0.5; + } + buttonGroupAlpha = buttonPulseMinAlpha + s * (buttonPulseMaxAlpha - buttonPulseMinAlpha); + } else { + buttonGroupAlpha = 1.0; + } + + // Update flash decay + if (buttonFlash > 0.0) { + buttonFlash -= frameMs * buttonFlashDecay; + if (buttonFlash < 0.0) buttonFlash = 0.0; + } } void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { @@ -178,124 +635,185 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi // Compute content offsets (same approach as main.cpp for proper centering) float winW = (float)logicalVP.w; float winH = (float)logicalVP.h; - float contentW = LOGICAL_W * logicalScale; - float contentH = LOGICAL_H * logicalScale; - float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; - float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W, LOGICAL_H, logicalScale, contentOffsetX, contentOffsetY); + + // Cache logical viewport/scale for update() so it can compute HUD target positions + lastLogicalScale = logicalScale; + lastLogicalVP = logicalVP; // Background is drawn by main (stretched to the full window) to avoid double-draw. - - // Draw the animated logo and fireworks using the small logo if available (show whole image) - SDL_Texture* logoToUse = ctx.logoSmallTex ? ctx.logoSmallTex : ctx.logoTex; - // Trace logo texture pointer { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render logoToUse=%llu\n", (unsigned long long)(uintptr_t)logoToUse); fclose(f); } - } - if (logoToUse) { - // Use dimensions provided by the shared context when available - int texW = (logoToUse == ctx.logoSmallTex && ctx.logoSmallW > 0) ? ctx.logoSmallW : 872; - int texH = (logoToUse == ctx.logoSmallTex && ctx.logoSmallH > 0) ? ctx.logoSmallH : 273; - float maxW = LOGICAL_W * 0.6f; - float scale = std::min(1.0f, maxW / float(texW)); - float dw = texW * scale; - float dh = texH * scale; - float logoX = (LOGICAL_W - dw) / 2.f + contentOffsetX; - float logoY = LOGICAL_H * 0.05f + contentOffsetY; - SDL_FRect dst{logoX, logoY, dw, dh}; - // Trace before rendering logo - { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render before SDL_RenderTexture logo ptr=%llu\n", (unsigned long long)(uintptr_t)logoToUse); fclose(f); } - } - SDL_RenderTexture(renderer, logoToUse, nullptr, &dst); - // Trace after rendering logo - { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render after SDL_RenderTexture logo\n"); fclose(f); } + FILE* f = fopen("tetris_trace.log", "a"); + if (f) { + fprintf(f, "MenuState::render ctx.mainScreenTex=%llu (w=%d h=%d)\n", + (unsigned long long)(uintptr_t)ctx.mainScreenTex, + ctx.mainScreenW, + ctx.mainScreenH); + fclose(f); } } - // Fireworks (draw above high scores / near buttons) - { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render before drawFireworks blocksTex=%llu\n", (unsigned long long)(uintptr_t)ctx.blocksTex); fclose(f); } - } - GlobalState::instance().drawFireworks(renderer, ctx.blocksTex); - { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render after drawFireworks\n"); fclose(f); } - } - - // Score list and top players with a sine-wave vertical animation (use pixelFont for retro look) - float topPlayersY = LOGICAL_H * 0.30f + contentOffsetY; // more top padding FontAtlas* useFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; - if (useFont) { - { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render before useFont->draw TOP PLAYERS ptr=%llu\n", (unsigned long long)(uintptr_t)useFont); fclose(f); } - } - const std::string title = "TOP PLAYERS"; - int tW = 0, tH = 0; useFont->measure(title, 1.8f, tW, tH); - float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX; - useFont->draw(renderer, titleX, topPlayersY, title, 1.8f, SDL_Color{255, 220, 0, 255}); - { - FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render after useFont->draw TOP PLAYERS\n"); fclose(f); } - } - } + // Slide-space amount for the options HUD (how much highscores move) + const float moveAmount = 420.0f; // increased so lower score rows slide further up - // High scores table with wave offset - float scoresStartY = topPlayersY + 70; // more spacing under title + // Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown. + float combinedTransition = static_cast(std::max(std::max(optionsTransition, levelTransition), exitTransition)); + float eased = combinedTransition * combinedTransition * (3.0f - 2.0f * combinedTransition); // cubic smoothstep + float panelDelta = eased * moveAmount; + + // Draw a larger centered logo above the highscores area, then a small "TOP PLAYER" label + // Move logo a bit lower for better spacing + float menuYOffset = LOGICAL_H * 0.03f; // same offset used for buttons + float topPlayersY = LOGICAL_H * 0.20f + contentOffsetY - panelDelta + menuYOffset; + float scoresStartY = topPlayersY; + if (useFont) { + // Preferred logo texture (full) if present, otherwise the small logo + SDL_Texture* logoTex = ctx.logoTex ? ctx.logoTex : ctx.logoSmallTex; + float logoDrawH = 72.0f; // larger logo height + if (logoTex) { + float texW = 0.0f, texH = 0.0f; + SDL_GetTextureSize(logoTex, &texW, &texH); + if (texW > 0.0f && texH > 0.0f) { + float scale = logoDrawH / texH; + float drawW = texW * scale; + SDL_FRect dst{ (LOGICAL_W - drawW) * 0.5f + contentOffsetX, topPlayersY, drawW, logoDrawH }; + SDL_SetTextureAlphaMod(logoTex, 230); + SDL_RenderTexture(renderer, logoTex, nullptr, &dst); + // move down for title under logo + scoresStartY = dst.y + dst.h + 8.0f; + } + } + + // Small TOP PLAYER label under the logo + const std::string smallTitle = "TOP PLAYER"; + float titleScale = 0.9f; + int tW = 0, tH = 0; + useFont->measure(smallTitle, titleScale, tW, tH); + float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX; + useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255}); + scoresStartY += (float)tH + 12.0f; + } static const std::vector EMPTY_SCORES; const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES; - size_t maxDisplay = std::min(hs.size(), size_t(12)); + size_t maxDisplay = std::min(hs.size(), size_t(10)); // display only top 10 - // Draw table header + // Draw highscores as an inline HUD-like panel (no opaque box), matching Options/Level/Exit style if (useFont) { - float cx = LOGICAL_W * 0.5f + contentOffsetX; - float colX[] = { cx - 280, cx - 180, cx - 20, cx + 90, cx + 200, cx + 300 }; - useFont->draw(renderer, colX[0], scoresStartY - 28, "RANK", 1.1f, SDL_Color{200,200,220,255}); - useFont->draw(renderer, colX[1], scoresStartY - 28, "PLAYER", 1.1f, SDL_Color{200,200,220,255}); - useFont->draw(renderer, colX[2], scoresStartY - 28, "SCORE", 1.1f, SDL_Color{200,200,220,255}); - useFont->draw(renderer, colX[3], scoresStartY - 28, "LINES", 1.1f, SDL_Color{200,200,220,255}); - useFont->draw(renderer, colX[4], scoresStartY - 28, "LEVEL", 1.1f, SDL_Color{200,200,220,255}); - useFont->draw(renderer, colX[5], scoresStartY - 28, "TIME", 1.1f, SDL_Color{200,200,220,255}); + const float panelW = std::min(780.0f, LOGICAL_W * 0.85f); + const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows + // Shift the entire highscores panel slightly left (~1.5% of logical width) + float panelShift = LOGICAL_W * 0.015f; + float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX - panelShift; + float panelBaseY = scoresStartY - 20.0f - panelDelta; // nudge up and apply HUD slide + + // Column positions inside panel + float colLeft = panelBaseX + 12.0f; + float colWidth = panelW - 24.0f; + // Center the column group inside the panel and place columns relative to center + float centerX = panelBaseX + panelW * 0.5f; + // Tighter column spacing: compress multipliers around center + float rankX = centerX - colWidth * 0.34f; + // Move PLAYER column a bit further left while leaving others unchanged + float nameX = centerX - colWidth * 0.25f; + // Move SCORE column slightly left for tighter layout + float scoreX = centerX - colWidth * 0.06f; + float linesX = centerX + colWidth * 0.14f; + float levelX = centerX + colWidth * 0.26f; + float timeX = centerX + colWidth * 0.38f; + + // Column header labels + float headerY = panelBaseY + 26.0f; + // Slightly smaller header for compactness + float headerScale = 0.75f; + // Use same color as Options heading (use full alpha for maximum brightness) + SDL_Color headerColor = SDL_Color{120,220,255,255}; + useFont->draw(renderer, rankX, headerY, "#", headerScale, headerColor); + useFont->draw(renderer, nameX, headerY, "PLAYER", headerScale, headerColor); + useFont->draw(renderer, scoreX, headerY, "SCORE", headerScale, headerColor); + useFont->draw(renderer, linesX, headerY, "LINES", headerScale, headerColor); + useFont->draw(renderer, levelX, headerY, "LVL", headerScale, headerColor); + useFont->draw(renderer, timeX, headerY, "TIME", headerScale, headerColor); + + const float rowHeight = 28.0f; + float rowY = panelBaseY + 28.0f; + float rowScale = 0.80f; + + for (size_t i = 0; i < maxDisplay; ++i) { + float y = rowY + i * rowHeight; + + // (removed thin blue separator between rows per request) + + // Subtle highlight wave for the list similar to before + float wave = std::sin((float)GlobalState::instance().logoAnimCounter * 0.006f + i * 0.25f) * 4.0f; + + // Per-row entrance staggering and easing to make movement fancier + const float baseEntrance = 40.0f; // pixels rows slide from + const float perRowDelay = 0.06f; // stagger delay per row (in eased 0..1 space) + float rowDelay = perRowDelay * (float)i; + float rowT = 0.0f; + if (eased > rowDelay) rowT = (eased - rowDelay) / (1.0f - rowDelay); + if (rowT < 0.0f) rowT = 0.0f; if (rowT > 1.0f) rowT = 1.0f; + // cubic smoothstep per row + float rowEase = rowT * rowT * (3.0f - 2.0f * rowT); + float entryOffset = (1.0f - rowEase) * baseEntrance; + // Slight alpha fade during entrance + float alphaMul = 1; + + // Slight scale slip per row (keeps earlier visual taper) + float curRowScale = rowScale - std::min(0.20f, 0.05f * (float)i); + + // Base row color matches header brightness; top 3 get vivid medal colors + SDL_Color baseRowColor = SDL_Color{ headerColor.r, headerColor.g, headerColor.b, 255 }; + if (i == 0) { + baseRowColor = SDL_Color{255,215,0,255}; // bright gold + } else if (i == 1) { + baseRowColor = SDL_Color{230,230,235,255}; // bright silver + } else if (i == 2) { + baseRowColor = SDL_Color{255,165,80,255}; // brighter bronze/orange + } + SDL_Color rowColor = baseRowColor; + // Use entrance alpha to fade in but keep RGB full-brightness; map alphaMul to 0..1 + rowColor.a = static_cast(std::round(255.0f * alphaMul)); + + // horizontal subtle slide for name column to add a little polish + float nameXAdj = nameX - (1.0f - rowEase) * 8.0f; + + char rankStr[8]; std::snprintf(rankStr, sizeof(rankStr), "%zu.", i + 1); + useFont->draw(renderer, rankX, y + wave + entryOffset, rankStr, curRowScale, rowColor); + + useFont->draw(renderer, nameXAdj, y + wave + entryOffset, hs[i].name, curRowScale, rowColor); + + char scoreStr[16]; std::snprintf(scoreStr, sizeof(scoreStr), "%d", hs[i].score); + useFont->draw(renderer, scoreX, y + wave + entryOffset, scoreStr, curRowScale, rowColor); + + char linesStr[8]; std::snprintf(linesStr, sizeof(linesStr), "%d", hs[i].lines); + useFont->draw(renderer, linesX, y + wave + entryOffset, linesStr, curRowScale, rowColor); + + char levelStr[8]; std::snprintf(levelStr, sizeof(levelStr), "%d", hs[i].level); + useFont->draw(renderer, levelX, y + wave + entryOffset, levelStr, curRowScale, rowColor); + + char timeStr[16]; int mins = int(hs[i].timeSec) / 60; int secs = int(hs[i].timeSec) % 60; + std::snprintf(timeStr, sizeof(timeStr), "%d:%02d", mins, secs); + useFont->draw(renderer, timeX, y + wave + entryOffset, timeStr, curRowScale, rowColor); + } } - - // Center columns around mid X, wider - float cx = LOGICAL_W * 0.5f + contentOffsetX; - float colX[] = { cx - 280, cx - 180, cx - 20, cx + 90, cx + 200, cx + 300 }; - - for (size_t i = 0; i < maxDisplay; ++i) - { - float baseY = scoresStartY + i * 25; - float wave = std::sin((float)GlobalState::instance().logoAnimCounter * 0.006f + i * 0.25f) * 6.0f; // subtle wave - float y = baseY + wave; - char rankStr[8]; - std::snprintf(rankStr, sizeof(rankStr), "%zu.", i + 1); - if (useFont) useFont->draw(renderer, colX[0], y, rankStr, 1.0f, SDL_Color{220, 220, 230, 255}); - - if (useFont) useFont->draw(renderer, colX[1], y, hs[i].name, 1.0f, SDL_Color{220, 220, 230, 255}); - - char scoreStr[16]; std::snprintf(scoreStr, sizeof(scoreStr), "%d", hs[i].score); - if (useFont) useFont->draw(renderer, colX[2], y, scoreStr, 1.0f, SDL_Color{220, 220, 230, 255}); - - char linesStr[8]; std::snprintf(linesStr, sizeof(linesStr), "%d", hs[i].lines); - if (useFont) useFont->draw(renderer, colX[3], y, linesStr, 1.0f, SDL_Color{220, 220, 230, 255}); - - char levelStr[8]; std::snprintf(levelStr, sizeof(levelStr), "%d", hs[i].level); - if (useFont) useFont->draw(renderer, colX[4], y, levelStr, 1.0f, SDL_Color{220, 220, 230, 255}); - - char timeStr[16]; int mins = int(hs[i].timeSec) / 60; int secs = int(hs[i].timeSec) % 60; - std::snprintf(timeStr, sizeof(timeStr), "%d:%02d", mins, secs); - if (useFont) useFont->draw(renderer, colX[5], y, timeStr, 1.0f, SDL_Color{220, 220, 230, 255}); - } + // The main_screen overlay is drawn by main.cpp as the background + // We don't need to draw it again here as a logo // Draw bottom action buttons with responsive sizing (reduced to match main mouse hit-test) // Use the contentW calculated at the top with content offsets + float contentW = LOGICAL_W * logicalScale; bool isSmall = (contentW < 700.0f); - float btnW = isSmall ? (LOGICAL_W * 0.32f) : (LOGICAL_W * 0.18f); - btnW = std::clamp(btnW, 180.0f, 260.0f); // keep buttons from consuming entire row - float btnH = isSmall ? 56.0f : 64.0f; + // Adjust button dimensions to match the background button graphics + float btnW = 200.0f; // Fixed width to match background buttons + float btnH = 70.0f; // Fixed height to match background buttons float btnX = LOGICAL_W * 0.5f + contentOffsetX; - // Move buttons down by 40px to match original layout (user requested 30-50px) - const float btnYOffset = 40.0f; - float btnY = LOGICAL_H * 0.86f + contentOffsetY + btnYOffset; // align with main's button vertical position + // Adjust vertical position to align with background buttons; move slightly down + float btnY = LOGICAL_H * 0.865f + contentOffsetY + (LOGICAL_H * 0.02f) + menuYOffset; if (ctx.pixelFont) { { @@ -305,26 +823,6 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0; std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); - auto drawMenuButtonLocal = [&](SDL_Renderer* r, FontAtlas& font, float cx, float cy, float w, float h, const std::string& label, SDL_Color bg, SDL_Color border, bool selected){ - float x = cx - w/2; float y = cy - h/2; - if (selected) { - SDL_SetRenderDrawColor(r, 255, 220, 0, 110); - SDL_FRect glow{ x-10, y-10, w+20, h+20 }; - SDL_RenderFillRect(r, &glow); - } - SDL_SetRenderDrawColor(r, border.r, border.g, border.b, border.a); - SDL_FRect br{ x-6, y-6, w+12, h+12 }; SDL_RenderFillRect(r, &br); - SDL_SetRenderDrawColor(r, 255,255,255,255); SDL_FRect br2{ x-4, y-4, w+8, h+8 }; SDL_RenderFillRect(r, &br2); - SDL_SetRenderDrawColor(r, bg.r, bg.g, bg.b, bg.a); SDL_FRect br3{ x, y, w, h }; SDL_RenderFillRect(r, &br3); - float textScale = 1.5f; - int textW = 0, textH = 0; - font.measure(label, textScale, textW, textH); - float tx = x + (w - static_cast(textW)) * 0.5f; - float ty = y + (h - static_cast(textH)) * 0.5f; - font.draw(r, tx + 2.0f, ty + 2.0f, label, textScale, SDL_Color{0, 0, 0, 200}); - font.draw(r, tx, ty, label, textScale, SDL_Color{255, 255, 255, 255}); - }; - struct MenuButtonDef { SDL_Color bg; SDL_Color border; @@ -337,137 +835,330 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi MenuButtonDef{ SDL_Color{130,80,210,255}, SDL_Color{90,40,170,255}, "OPTIONS" }, MenuButtonDef{ SDL_Color{200,70,70,255}, SDL_Color{150,40,40,255}, "EXIT" } }; + + // Icon array (nullptr if icon not loaded) + std::array icons = { + playIcon, + levelIcon, + optionsIcon, + exitIcon + }; + // Fixed spacing to match background button positions float spacing = isSmall ? btnW * 1.2f : btnW * 1.15f; - for (size_t i = 0; i < buttons.size(); ++i) { - float offset = (static_cast(i) - 1.5f) * spacing; - float cx = btnX + offset; - drawMenuButtonLocal(renderer, *ctx.pixelFont, cx, btnY, btnW, btnH, buttons[i].label, buttons[i].bg, buttons[i].border, selectedButton == static_cast(i)); + + // Draw each button individually so each can have its own coordinates + if (drawMainButtonNormally) { + // Draw semi-transparent background panel behind the full button group + { + float groupCenterX = btnX; + float halfSpan = 1.5f * spacing; + float panelLeft = groupCenterX - halfSpan - btnW * 0.5f - 14.0f; + float panelRight = groupCenterX + halfSpan + btnW * 0.5f + 14.0f; + float panelTop = btnY - btnH * 0.5f - 12.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, less-opaque background to increase contrast (match top path) + SDL_SetRenderDrawColor(renderer, 28, 36, 46, 180); + // 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); + // subtle border across full logical width + SDL_SetRenderDrawColor(renderer, 120, 140, 160, 200); + // Expand border to cover full window width (use actual viewport) + SDL_FRect borderFull{ 0.0f, panelTop, viewportLogicalW, panelH }; + SDL_RenderRect(renderer, &borderFull); + } + // Button 0 - PLAY + { + const int i = 0; + float cxCenter = 0.0f; + float cyCenter = btnY; + if (ctx.menuButtonsExplicit) { + cxCenter = ctx.menuButtonCX[i] + contentOffsetX; + cyCenter = ctx.menuButtonCY[i] + contentOffsetY; + } else { + float offset = (static_cast(i) - 1.5f) * spacing; + cxCenter = btnX + offset + 15.0f; + } + UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, + buttons[i].label, false, selectedButton == i, + buttons[i].bg, buttons[i].border, true, icons[i]); + // no per-button neon outline; group background handles emphasis + } + + // Button 1 - LEVEL + { + const int i = 1; + float cxCenter = 0.0f; + float cyCenter = btnY; + if (ctx.menuButtonsExplicit) { + cxCenter = ctx.menuButtonCX[i] + contentOffsetX; + cyCenter = ctx.menuButtonCY[i] + contentOffsetY; + } else { + float offset = (static_cast(i) - 1.5f) * spacing; + cxCenter = btnX + offset; + } + UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, + buttons[i].label, false, selectedButton == i, + buttons[i].bg, buttons[i].border, true, icons[i]); + } + + // Button 2 - OPTIONS + { + const int i = 2; + float cxCenter = 0.0f; + float cyCenter = btnY; + if (ctx.menuButtonsExplicit) { + cxCenter = ctx.menuButtonCX[i] + contentOffsetX; + cyCenter = ctx.menuButtonCY[i] + contentOffsetY; + } else { + float offset = (static_cast(i) - 1.5f) * spacing; + cxCenter = btnX + offset - 24.0f; + } + UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, + buttons[i].label, false, selectedButton == i, + buttons[i].bg, buttons[i].border, true, icons[i]); + } + + // Button 3 - EXIT + { + const int i = 3; + float cxCenter = 0.0f; + float cyCenter = btnY; + if (ctx.menuButtonsExplicit) { + cxCenter = ctx.menuButtonCX[i] + contentOffsetX; + cyCenter = ctx.menuButtonCY[i] + contentOffsetY; + } else { + float offset = (static_cast(i) - 1.5f) * spacing; + cxCenter = btnX + offset - 44.0f; + } + UIRenderer::drawButton(renderer, ctx.pixelFont, cxCenter, cyCenter, btnW, btnH, + buttons[i].label, false, selectedButton == i, + buttons[i].bg, buttons[i].border, true, icons[i]); + } } } - if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) { - int selection = ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1; - - // Switch to window coordinates for full-screen overlay - SDL_SetRenderViewport(renderer, nullptr); - SDL_SetRenderScale(renderer, 1.0f, 1.0f); - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 150); - - // Get actual window size - int actualWinW = 0, actualWinH = 0; - SDL_GetRenderOutputSize(renderer, &actualWinW, &actualWinH); - SDL_FRect overlay{0, 0, (float)actualWinW, (float)actualWinH}; - SDL_RenderFillRect(renderer, &overlay); - - // Restore viewport and scale for popup content - SDL_SetRenderViewport(renderer, &logicalVP); - SDL_SetRenderScale(renderer, logicalScale, logicalScale); + // Inline exit HUD (no opaque background) - slides into the highscores area + if (exitTransition > 0.0) { + float easedE = static_cast(exitTransition); + easedE = easedE * easedE * (3.0f - 2.0f * easedE); + const float panelW = 520.0f; + const float panelH = 360.0f; + float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX; + float panelBaseY = (LOGICAL_H - panelH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); + float slideAmount = LOGICAL_H * 0.42f; + float panelY = panelBaseY + (1.0f - easedE) * slideAmount; - const float panelW = 640.0f; - const float panelH = 320.0f; - SDL_FRect panel{ - (LOGICAL_W - panelW) * 0.5f + contentOffsetX, - (LOGICAL_H - panelH) * 0.5f + contentOffsetY, - panelW, - panelH + FontAtlas* titleFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; + if (titleFont) titleFont->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "EXIT GAME?", 1.6f, SDL_Color{255,200,80,255}); + + SDL_FRect area{ panelBaseX + 12.0f, panelY + 56.0f, panelW - 24.0f, panelH - 120.0f }; + // Sample long message (scrollable) + // Paragraph-style lines for a nicer exit confirmation message + std::vector lines = { + "Quit now to return to your desktop. Your current session will end.", + "Press YES to quit immediately, or NO to return to the menu and continue playing.", + "Adjust audio, controls and other settings anytime from the Options menu.", + "Thanks for playing SPACETRIS โ€” we hope to see you again!" }; - SDL_FRect shadow{panel.x + 6.0f, panel.y + 10.0f, panel.w, panel.h}; - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 140); - SDL_RenderFillRect(renderer, &shadow); - for (int i = 0; i < 5; ++i) { - SDL_FRect glow{panel.x - float(i * 2), panel.y - float(i * 2), panel.w + float(i * 4), panel.h + float(i * 4)}; - SDL_SetRenderDrawColor(renderer, 0, 180, 255, Uint8(44 - i * 7)); - SDL_RenderRect(renderer, &glow); - } - - SDL_SetRenderDrawColor(renderer, 18, 30, 52, 255); - SDL_RenderFillRect(renderer, &panel); - SDL_SetRenderDrawColor(renderer, 70, 120, 210, 255); - SDL_RenderRect(renderer, &panel); - - SDL_FRect inner{panel.x + 24.0f, panel.y + 98.0f, panel.w - 48.0f, panel.h - 146.0f}; - SDL_SetRenderDrawColor(renderer, 16, 24, 40, 235); - SDL_RenderFillRect(renderer, &inner); - SDL_SetRenderDrawColor(renderer, 40, 80, 140, 235); - SDL_RenderRect(renderer, &inner); - - FontAtlas* retroFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; - if (retroFont) { - const float titleScale = 1.9f; - const char* title = "EXIT GAME?"; - int titleW = 0, titleH = 0; - retroFont->measure(title, titleScale, titleW, titleH); - float titleX = panel.x + (panel.w - static_cast(titleW)) * 0.5f; - retroFont->draw(renderer, titleX, panel.y + 30.0f, title, titleScale, SDL_Color{255, 230, 140, 255}); - - const float bodyScale = 1.05f; - const char* line = "Are you sure you want to quit?"; - int bodyW = 0, bodyH = 0; - retroFont->measure(line, bodyScale, bodyW, bodyH); - float bodyX = panel.x + (panel.w - static_cast(bodyW)) * 0.5f; - retroFont->draw(renderer, bodyX, inner.y + 18.0f, line, bodyScale, SDL_Color{210, 220, 240, 255}); - } - - const float horizontalPad = 28.0f; - const float buttonGap = 32.0f; - const float buttonH = 66.0f; - float buttonW = (inner.w - horizontalPad * 2.0f - buttonGap) * 0.5f; - float buttonY = inner.y + inner.h - buttonH - 24.0f; - - auto drawChoice = [&](int idx, float x, const char* label) { - bool selected = (selection == idx); - SDL_Color base = (idx == 0) ? SDL_Color{185, 70, 70, 255} : SDL_Color{60, 95, 150, 255}; - SDL_Color body = selected ? SDL_Color{Uint8(std::min(255, base.r + 35)), Uint8(std::min(255, base.g + 35)), Uint8(std::min(255, base.b + 35)), 255} : base; - SDL_Color border = selected ? SDL_Color{255, 220, 120, 255} : SDL_Color{80, 110, 160, 255}; - - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 120); - SDL_FRect shadowRect{x + 4.0f, buttonY + 6.0f, buttonW, buttonH}; - SDL_RenderFillRect(renderer, &shadowRect); - - SDL_FRect bodyRect{x, buttonY, buttonW, buttonH}; - SDL_SetRenderDrawColor(renderer, body.r, body.g, body.b, body.a); - SDL_RenderFillRect(renderer, &bodyRect); - SDL_SetRenderDrawColor(renderer, border.r, border.g, border.b, border.a); - SDL_RenderRect(renderer, &bodyRect); - - if (retroFont) { - const float labelScale = 1.4f; - int textW = 0, textH = 0; - retroFont->measure(label, labelScale, textW, textH); - float textX = bodyRect.x + (bodyRect.w - static_cast(textW)) * 0.5f; - float textY = bodyRect.y + (bodyRect.h - static_cast(textH)) * 0.5f; - SDL_Color textColor = selected ? SDL_Color{255, 255, 255, 255} : SDL_Color{230, 235, 250, 255}; - retroFont->draw(renderer, textX, textY, label, labelScale, textColor); + // Draw scrollable text (no background box; increased line spacing) + FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; + float y = area.y - (float)exitScroll; + const float lineSpacing = 34.0f; // increased spacing for readability + if (f) { + for (size_t i = 0; i < lines.size(); ++i) { + f->draw(renderer, area.x + 6.0f, y + i * lineSpacing, lines[i], 1.0f, SDL_Color{200,220,240,255}); } - }; + } - float yesX = inner.x + horizontalPad; - float noX = yesX + buttonW + buttonGap; - drawChoice(0, yesX, "YES"); - drawChoice(1, noX, "NO"); + // Draw buttons at bottom of panel + float btnW2 = 160.0f, btnH2 = 48.0f; + float bx = panelBaseX + (panelW - (btnW2 * 2.0f + 12.0f)) * 0.5f; + float by = panelY + panelH - 56.0f; + // YES button + SDL_Color yesBg{220,80,60, 200}; SDL_Color yesBorder{160,40,40,200}; + SDL_Color noBg{60,140,200,200}; SDL_Color noBorder{30,90,160,200}; + // Apply pulse alpha to buttons + double aMul = std::clamp(buttonGroupAlpha + buttonFlash * buttonFlashAmount, 0.0, 1.0); + yesBg.a = static_cast(std::round(aMul * static_cast(yesBg.a))); + yesBorder.a = static_cast(std::round(aMul * static_cast(yesBorder.a))); + noBg.a = static_cast(std::round(aMul * static_cast(noBg.a))); + noBorder.a = static_cast(std::round(aMul * static_cast(noBorder.a))); + + UIRenderer::drawButton(renderer, ctx.pixelFont, bx + btnW2*0.5f, by, btnW2, btnH2, "YES", false, exitSelectedButton == 0, yesBg, yesBorder, true, nullptr); + UIRenderer::drawButton(renderer, ctx.pixelFont, bx + btnW2*1.5f + 12.0f, by, btnW2, btnH2, "NO", false, exitSelectedButton == 1, noBg, noBorder, true, nullptr); + + // Ensure ctx mirrors selection for any other code + if (ctx.exitPopupSelectedButton) *ctx.exitPopupSelectedButton = exitSelectedButton; } // Popups (settings only - level popup is now a separate state) if (ctx.showSettingsPopup && *ctx.showSettingsPopup) { - // draw settings popup inline bool musicOn = ctx.musicEnabled ? *ctx.musicEnabled : true; - float popupW = 350, popupH = 260; - float popupX = (LOGICAL_W - popupW) / 2; - float popupY = (LOGICAL_H - popupH) / 2; - SDL_SetRenderDrawColor(renderer, 0,0,0,128); SDL_FRect overlay{0,0,(float)LOGICAL_W,(float)LOGICAL_H}; SDL_RenderFillRect(renderer, &overlay); - SDL_SetRenderDrawColor(renderer, 100,120,160,255); SDL_FRect bord{popupX-4,popupY-4,popupW+8,popupH+8}; SDL_RenderFillRect(renderer, &bord); - SDL_SetRenderDrawColor(renderer, 40,50,70,255); SDL_FRect body{popupX,popupY,popupW,popupH}; SDL_RenderFillRect(renderer, &body); - ctx.font->draw(renderer, popupX + 20, popupY + 20, "SETTINGS", 2.0f, SDL_Color{255,220,0,255}); - ctx.font->draw(renderer, popupX + 20, popupY + 70, "MUSIC:", 1.5f, SDL_Color{255,255,255,255}); - ctx.font->draw(renderer, popupX + 120, popupY + 70, musicOn ? "ON" : "OFF", 1.5f, musicOn ? SDL_Color{0,255,0,255} : SDL_Color{255,0,0,255}); - ctx.font->draw(renderer, popupX + 20, popupY + 100, "SOUND FX:", 1.5f, SDL_Color{255,255,255,255}); - ctx.font->draw(renderer, popupX + 140, popupY + 100, "ON", 1.5f, SDL_Color{0,255,0,255}); - ctx.font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, SDL_Color{200,200,220,255}); - ctx.font->draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255}); - ctx.font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, SDL_Color{200,200,220,255}); + bool soundOn = SoundEffectManager::instance().isEnabled(); + UIRenderer::drawSettingsPopup(renderer, ctx.font, LOGICAL_W, LOGICAL_H, musicOn, soundOn); + } + + // Draw animated options panel if in use (either animating or visible) + if (optionsTransition > 0.0) { + // HUD-style overlay: no opaque background; draw labels/values directly with separators + const float panelW = 520.0f; + const float panelH = 420.0f; + float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX; + // Move the HUD higher by ~10% of logical height so it sits above center + float panelBaseY = (LOGICAL_H - panelH) * 0.5f + contentOffsetY - (LOGICAL_H * 0.10f); + float panelY = panelBaseY + (1.0f - eased) * moveAmount; + + // For options/settings we prefer the secondary (Exo2) font for longer descriptions. + FontAtlas* retroFont = ctx.font ? ctx.font : ctx.pixelFont; + + if (retroFont) { + retroFont->draw(renderer, panelBaseX + 12.0f, panelY + 6.0f, "OPTIONS", 1.8f, SDL_Color{120, 220, 255, 255}); + } + + SDL_FRect area{panelBaseX, panelY + 48.0f, panelW, panelH - 64.0f}; + constexpr int rowCount = 5; + const float rowHeight = 64.0f; + const float spacing = 8.0f; + + auto drawField = [&](int idx, float y, const std::string& label, const std::string& value) { + SDL_FRect row{area.x, y, area.w, rowHeight}; + + // Draw thin separator (1px high filled rect) so we avoid platform-specific line API differences + SDL_SetRenderDrawColor(renderer, 60, 120, 160, 120); + SDL_FRect sep{ row.x + 6.0f, row.y + row.h - 1.0f, row.w - 12.0f, 1.0f }; + SDL_RenderFillRect(renderer, &sep); + + // Highlight the selected row with a subtle outline + if (idx == optionsSelectedRow) { + SDL_SetRenderDrawColor(renderer, 80, 200, 255, 120); + SDL_RenderRect(renderer, &row); + } + + if (retroFont) { + SDL_Color labelColor = SDL_Color{170, 210, 220, 255}; + SDL_Color valueColor = SDL_Color{160, 240, 255, 255}; + if (!label.empty()) { + float labelScale = 1.0f; + int labelW = 0, labelH = 0; + retroFont->measure(label, labelScale, labelW, labelH); + float labelY = row.y + (row.h - static_cast(labelH)) * 0.5f; + retroFont->draw(renderer, row.x + 16.0f, labelY, label, labelScale, labelColor); + } + int valueW = 0, valueH = 0; + float valueScale = 1.4f; + retroFont->measure(value, valueScale, valueW, valueH); + float valX = row.x + row.w - static_cast(valueW) - 16.0f; + float valY = row.y + (row.h - valueH) * 0.5f; + retroFont->draw(renderer, valX, valY, value, valueScale, valueColor); + } + }; + + float rowY = area.y + spacing; + // FULLSCREEN + bool isFS = ctx.fullscreenFlag ? *ctx.fullscreenFlag : Settings::instance().isFullscreen(); + drawField(0, rowY, "FULLSCREEN", isFS ? "ON" : "OFF"); + rowY += rowHeight + spacing; + // MUSIC + bool musicOn = ctx.musicEnabled ? *ctx.musicEnabled : Settings::instance().isMusicEnabled(); + drawField(1, rowY, "MUSIC", musicOn ? "ON" : "OFF"); + rowY += rowHeight + spacing; + // SOUND FX + bool soundOn = SoundEffectManager::instance().isEnabled(); + drawField(2, rowY, "SOUND FX", soundOn ? "ON" : "OFF"); + rowY += rowHeight + spacing; + // SMOOTH SCROLL + bool smooth = Settings::instance().isSmoothScrollEnabled(); + drawField(3, rowY, "SMOOTH SCROLL", smooth ? "ON" : "OFF"); + rowY += rowHeight + spacing; + // RETURN TO MENU + drawField(4, rowY, "", "RETURN TO MENU"); + } + + // Draw inline level selector HUD (no background) if active + if (levelTransition > 0.0) { + float easedL = static_cast(levelTransition); + easedL = easedL * easedL * (3.0f - 2.0f * easedL); + const float PW = std::min(520.0f, LOGICAL_W * 0.65f); + const float PH = std::min(360.0f, LOGICAL_H * 0.7f); + 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 - easedL) * slideAmount; + + // Header + FontAtlas* titleFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; + if (titleFont) titleFont->draw(renderer, panelBaseX + PW * 0.5f - 140.0f, panelY + 6.0f, "SELECT STARTING LEVEL", 1.1f, SDL_Color{160,220,255,255}); + + // Grid area + float marginX = 34.0f, marginY = 56.0f; + SDL_FRect area{ panelBaseX + marginX, panelY + marginY, PW - 2.0f * marginX, PH - marginY - 28.0f }; + const int cols = 4, rows = 5; + const float gapX = 12.0f, gapY = 12.0f; + float cellW = (area.w - (cols - 1) * gapX) / cols; + float cellH = (area.h - (rows - 1) * gapY) / rows; + + for (int i = 0; i < 20; ++i) { + int r = i / cols, c = i % cols; + SDL_FRect rc{ area.x + c * (cellW + gapX), area.y + r * (cellH + gapY), cellW, cellH }; + bool hovered = (levelSelected == i) || (levelHovered == i); + bool selected = (ctx.startLevelSelection && *ctx.startLevelSelection == i); + // Use the project's gold/yellow tone for selected level to match UI accents + SDL_Color selectedFill = SDL_Color{255,204,0,160}; + SDL_Color fill = selected ? selectedFill : (hovered ? SDL_Color{60,80,100,120} : SDL_Color{30,40,60,110}); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); + SDL_RenderFillRect(renderer, &rc); + SDL_SetRenderDrawColor(renderer, 80,100,120,160); + SDL_RenderRect(renderer, &rc); + + // Draw level number + FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; + if (f) { + char buf[8]; std::snprintf(buf, sizeof(buf), "%d", i); + int w=0,h=0; f->measure(buf, 1.6f, w, h); + f->draw(renderer, rc.x + (rc.w - w) * 0.5f, rc.y + (rc.h - h) * 0.5f, buf, 1.6f, SDL_Color{220,230,240,255}); + } + } + + // Draw animated highlight (interpolated) on top of cells + if (levelHighlightInitialized) { + float hx = (float)levelHighlightX; + float hy = (float)levelHighlightY; + float hw = cellW + 6.0f; + float hh = cellH + 6.0f; + // Draw multi-layer glow: outer faint, mid, inner bright + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + // Outer glow + SDL_SetRenderDrawColor(renderer, levelHighlightColor.r, levelHighlightColor.g, levelHighlightColor.b, (Uint8)(levelHighlightGlowAlpha * 80)); + SDL_FRect outer{ hx - (hw * 0.5f + 8.0f), hy - (hh * 0.5f + 8.0f), hw + 16.0f, hh + 16.0f }; + SDL_RenderRect(renderer, &outer); + // Mid glow + SDL_SetRenderDrawColor(renderer, levelHighlightColor.r, levelHighlightColor.g, levelHighlightColor.b, (Uint8)(levelHighlightGlowAlpha * 140)); + SDL_FRect mid{ hx - (hw * 0.5f + 4.0f), hy - (hh * 0.5f + 4.0f), hw + 8.0f, hh + 8.0f }; + SDL_RenderRect(renderer, &mid); + // Inner outline + SDL_SetRenderDrawColor(renderer, levelHighlightColor.r, levelHighlightColor.g, levelHighlightColor.b, levelHighlightColor.a); + SDL_FRect inner{ hx - hw * 0.5f, hy - hh * 0.5f, hw, hh }; + // Draw multiple rects to simulate thickness + for (int t = 0; t < levelHighlightThickness; ++t) { + SDL_FRect r{ inner.x - t, inner.y - t, inner.w + t * 2.0f, inner.h + t * 2.0f }; + SDL_RenderRect(renderer, &r); + } + } + + // Instructions + FontAtlas* foot = ctx.font ? ctx.font : ctx.pixelFont; + if (foot) foot->draw(renderer, panelBaseX + PW*0.5f - 160.0f, panelY + PH + 40.0f, "ARROWS = NAV โ€ข ENTER = SELECT โ€ข ESC = CANCEL", 0.9f, SDL_Color{160,180,200,200}); } // Trace exit { diff --git a/src/states/MenuState.h b/src/states/MenuState.h index c8949f5..0f5f5c1 100644 --- a/src/states/MenuState.h +++ b/src/states/MenuState.h @@ -10,7 +10,68 @@ public: void handleEvent(const SDL_Event& e) override; void update(double frameMs) override; void render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) override; + // When false, the main PLAY button is not drawn by `render()` and can be + // rendered separately with `renderMainButtonTop` (useful for layer ordering). + bool drawMainButtonNormally = true; + // Draw only the main PLAY button on top of other layers (expects logical coords). + void renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP); private: int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = EXIT + + // Button icons (optional - will use text if nullptr) + SDL_Texture* playIcon = nullptr; + SDL_Texture* levelIcon = nullptr; + SDL_Texture* optionsIcon = nullptr; + SDL_Texture* exitIcon = nullptr; + + // Options panel animation state + bool optionsVisible = false; + bool optionsAnimating = false; + double optionsTransition = 0.0; // 0..1 + double optionsTransitionDurationMs = 400.0; + int optionsDirection = 1; // 1 show, -1 hide + // Which row in the inline options panel is currently selected (0..4) + int optionsSelectedRow = 0; + // Inline level selector HUD state + bool levelPanelVisible = false; + bool levelPanelAnimating = false; + double levelTransition = 0.0; // 0..1 + double levelTransitionDurationMs = 400.0; + int levelDirection = 1; // 1 show, -1 hide + int levelHovered = -1; // hovered cell + int levelSelected = 0; // current selected level + // Cache logical viewport/scale for input conversion when needed + float lastLogicalScale = 1.0f; + SDL_Rect lastLogicalVP{0,0,0,0}; + // Animated highlight position (world/logical coordinates) + double levelHighlightX = 0.0; + double levelHighlightY = 0.0; + bool levelHighlightInitialized = false; + // Highlight tuning parameters + double levelHighlightSpeed = 0.018; // smoothing constant - higher = snappier + double levelHighlightGlowAlpha = 0.70; // 0..1 base glow alpha + int levelHighlightThickness = 3; // inner outline thickness (px) + SDL_Color levelHighlightColor = SDL_Color{80, 200, 255, 200}; + // Button group pulsing/fade parameters (applies to all four main buttons) + double buttonGroupAlpha = 1.0; // current computed alpha (0..1) + double buttonPulseTime = 0.0; // accumulator in ms + bool buttonPulseEnabled = true; // enable/disable pulsing + double buttonPulseSpeed = 1.0; // multiplier for pulse frequency + double buttonPulseMinAlpha = 0.60; // minimum alpha during pulse + double buttonPulseMaxAlpha = 1.00; // maximum alpha during pulse + // Pulse easing mode: 0=sin,1=triangle,2=exponential + int buttonPulseEasing = 1; + // Short bright flash when navigating buttons + double buttonFlash = 0.0; // transient flash amount (0..1) + double buttonFlashAmount = 0.45; // how much flash adds to group alpha + double buttonFlashDecay = 0.0025; // linear decay per ms + // Exit confirmation HUD state (inline HUD like Options/Level) + bool exitPanelVisible = false; + bool exitPanelAnimating = false; + double exitTransition = 0.0; // 0..1 + double exitTransitionDurationMs = 360.0; + int exitDirection = 1; // 1 show, -1 hide + int exitSelectedButton = 0; // 0 = YES (quit), 1 = NO (cancel) + double exitScroll = 0.0; // vertical scroll offset for content }; diff --git a/src/states/OptionsState.cpp b/src/states/OptionsState.cpp index 9fc3e2a..bec188d 100644 --- a/src/states/OptionsState.cpp +++ b/src/states/OptionsState.cpp @@ -7,6 +7,7 @@ #include #include #include "../core/Settings.h" +#include "../graphics/renderers/UIRenderer.h" OptionsState::OptionsState(StateContext& ctx) : State(ctx) {} @@ -89,32 +90,17 @@ void OptionsState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l float winW = static_cast(logicalVP.w); float winH = static_cast(logicalVP.h); - float contentW = LOGICAL_W * logicalScale; - float contentH = LOGICAL_H * logicalScale; - float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; - float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + UIRenderer::computeContentOffsets(winW, winH, LOGICAL_W, LOGICAL_H, logicalScale, contentOffsetX, contentOffsetY); SDL_Texture* logoTexture = ctx.logoSmallTex ? ctx.logoSmallTex : ctx.logoTex; - if (logoTexture) { - float texW = 0.0f; - float texH = 0.0f; - if (logoTexture == ctx.logoSmallTex && ctx.logoSmallW > 0 && ctx.logoSmallH > 0) { - texW = static_cast(ctx.logoSmallW); - texH = static_cast(ctx.logoSmallH); - } else { - SDL_GetTextureSize(logoTexture, &texW, &texH); - } - if (texW > 0.0f && texH > 0.0f) { - float maxWidth = LOGICAL_W * 0.6f; - float scale = std::min(1.0f, maxWidth / texW); - float dw = texW * scale; - float dh = texH * scale; - float logoX = (LOGICAL_W - dw) * 0.5f + contentOffsetX; - float logoY = LOGICAL_H * 0.05f + contentOffsetY; - SDL_FRect dst{logoX, logoY, dw, dh}; - SDL_RenderTexture(renderer, logoTexture, nullptr, &dst); - } + int logoW = 0, logoH = 0; + if (logoTexture == ctx.logoSmallTex && ctx.logoSmallW > 0) { + logoW = ctx.logoSmallW; + logoH = ctx.logoSmallH; } + UIRenderer::drawLogo(renderer, logoTexture, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY, logoW, logoH); const float panelW = 520.0f; const float panelH = 420.0f; @@ -125,25 +111,10 @@ void OptionsState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l panelH }; - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + UIRenderer::drawSciFiPanel(renderer, panel); - // Panel styling similar to level selector - SDL_FRect shadow{panel.x + 6.0f, panel.y + 10.0f, panel.w, panel.h}; - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 120); - SDL_RenderFillRect(renderer, &shadow); - - for (int i = 0; i < 5; ++i) { - SDL_FRect glow{panel.x - float(i * 2), panel.y - float(i * 2), panel.w + float(i * 4), panel.h + float(i * 4)}; - SDL_SetRenderDrawColor(renderer, 0, 180, 255, Uint8(42 - i * 8)); - SDL_RenderRect(renderer, &glow); - } - - SDL_SetRenderDrawColor(renderer, 18, 30, 52, 255); - SDL_RenderFillRect(renderer, &panel); - SDL_SetRenderDrawColor(renderer, 70, 120, 210, 255); - SDL_RenderRect(renderer, &panel); - - FontAtlas* retroFont = ctx.pixelFont ? ctx.pixelFont : ctx.font; + // For options/settings we prefer the secondary (Exo2) font for longer descriptions. + FontAtlas* retroFont = ctx.font ? ctx.font : ctx.pixelFont; if (!logoTexture && retroFont) { retroFont->draw(renderer, panel.x + 24.0f, panel.y + 24.0f, "OPTIONS", 2.0f, {255, 230, 120, 255}); @@ -282,9 +253,12 @@ void OptionsState::toggleSmoothScroll() { } void OptionsState::exitToMenu() { + // Try a graceful fade transition if available, but always ensure we + // return to the Menu state so the UI is responsive to the user's action. if (ctx.requestFadeTransition) { ctx.requestFadeTransition(AppState::Menu); - } else if (ctx.stateManager) { + } + if (ctx.stateManager) { ctx.stateManager->setState(AppState::Menu); } } diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index 01acecd..4e846b1 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -9,6 +9,10 @@ #include "../core/Config.h" #include +// File-scope transport/spawn detection state +static uint64_t s_lastPieceSequence = 0; +static bool s_pendingTransport = false; + PlayingState::PlayingState(StateContext& ctx) : State(ctx) {} void PlayingState::onEnter() { @@ -18,6 +22,12 @@ void PlayingState::onEnter() { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection); ctx.game->reset(*ctx.startLevelSelection); } + if (ctx.game) { + s_lastPieceSequence = ctx.game->getCurrentPieceSequence(); + s_pendingTransport = false; + } + + // (transport state is tracked at file scope) } void PlayingState::onExit() { @@ -28,6 +38,10 @@ void PlayingState::onExit() { } void PlayingState::handleEvent(const SDL_Event& e) { + // If a transport animation is active, ignore gameplay input entirely. + if (GameRenderer::isTransportActive()) { + return; + } // We keep short-circuited input here; main still owns mouse UI if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { if (!ctx.game) return; @@ -130,10 +144,21 @@ void PlayingState::update(double frameMs) { if (!ctx.game) return; ctx.game->updateVisualEffects(frameMs); + // If a transport animation is active, pause gameplay updates and ignore inputs + if (GameRenderer::isTransportActive()) { + // Keep visual effects updating but skip gravity/timers while transport runs + return; + } // forward per-frame gameplay updates (gravity, line effects) if (!ctx.game->isPaused()) { ctx.game->tickGravity(frameMs); + // Detect spawn event (sequence increment) and request transport effect + uint64_t seq = ctx.game->getCurrentPieceSequence(); + if (seq != s_lastPieceSequence) { + s_lastPieceSequence = seq; + s_pendingTransport = true; + } ctx.game->updateElapsedTime(); if (ctx.lineEffect && ctx.lineEffect->isActive()) { @@ -183,12 +208,22 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l SDL_SetRenderScale(renderer, logicalScale, logicalScale); // Render game content (no overlays) + // If a transport effect was requested due to a recent spawn, start it here so + // the renderer has the correct layout and renderer context to compute coords. + if (s_pendingTransport) { + GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f); + s_pendingTransport = false; + } + GameRenderer::renderPlayingState( renderer, ctx.game, ctx.pixelFont, ctx.lineEffect, ctx.blocksTex, + ctx.statisticsPanelTex, + ctx.scorePanelTex, + ctx.nextPanelTex, 1200.0f, // LOGICAL_W 1000.0f, // LOGICAL_H logicalScale, @@ -263,12 +298,19 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l } else { // Render normally directly to screen + if (s_pendingTransport) { + GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f); + s_pendingTransport = false; + } GameRenderer::renderPlayingState( renderer, ctx.game, ctx.pixelFont, ctx.lineEffect, ctx.blocksTex, + ctx.statisticsPanelTex, + ctx.scorePanelTex, + ctx.nextPanelTex, 1200.0f, 1000.0f, logicalScale, diff --git a/src/states/State.h b/src/states/State.h index 68bee3c..6aa99e6 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -5,6 +5,7 @@ #include #include #include +#include // Forward declarations for frequently used types class Game; @@ -39,6 +40,16 @@ struct StateContext { // backgroundTex is set once in `main.cpp` and passed to states via this context. // Prefer reading this field instead of relying on any `extern SDL_Texture*` globals. SDL_Texture* blocksTex = nullptr; + SDL_Texture* scorePanelTex = nullptr; + SDL_Texture* statisticsPanelTex = nullptr; + SDL_Texture* nextPanelTex = nullptr; + SDL_Texture* mainScreenTex = nullptr; + int mainScreenW = 0; + int mainScreenH = 0; + // Captured full-scene texture (used by menu for backdrop blur effects) + SDL_Texture* sceneTex = nullptr; + int sceneW = 0; + int sceneH = 0; // Audio / SFX - forward declared types in main // Pointers to booleans/flags used by multiple states @@ -63,6 +74,12 @@ struct StateContext { std::function requestFadeTransition; // Generic state fade requests (menu/options/level) // Pointer to the application's StateManager so states can request transitions StateManager* stateManager = nullptr; + // Optional explicit per-button coordinates (logical coordinates). When + // `menuButtonsExplicit` is true, MenuState will use these centers for + // rendering and hit tests. Values are in logical units (LOGICAL_W/H). + std::array menuButtonCX{}; + std::array menuButtonCY{}; + bool menuButtonsExplicit = false; }; class State {