diff --git a/IMPROVEMENTS_CHECKLIST.md b/IMPROVEMENTS_CHECKLIST.md deleted file mode 100644 index acf363f..0000000 --- a/IMPROVEMENTS_CHECKLIST.md +++ /dev/null @@ -1,363 +0,0 @@ -# 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 deleted file mode 100644 index 764c369..0000000 --- a/QUICK_START_IMPROVEMENTS.md +++ /dev/null @@ -1,774 +0,0 @@ -# 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/images/asteroids_001.png b/assets/images/asteroids_001.png new file mode 100644 index 0000000..f488f51 Binary files /dev/null and b/assets/images/asteroids_001.png differ diff --git a/assets/music/48997.wav b/assets/music/48997.wav deleted file mode 100644 index 4ea7d20..0000000 Binary files a/assets/music/48997.wav and /dev/null differ diff --git a/assets/music/GONG0.WAV b/assets/music/GONG0.WAV deleted file mode 100644 index 3a54e08..0000000 Binary files a/assets/music/GONG0.WAV and /dev/null differ diff --git a/assets/music/asteroid-destroy.mp3 b/assets/music/asteroid-destroy.mp3 new file mode 100644 index 0000000..f6338cc Binary files /dev/null and b/assets/music/asteroid-destroy.mp3 differ diff --git a/src/graphics/challenge_mode.md b/challenge_mode.md similarity index 100% rename from src/graphics/challenge_mode.md rename to challenge_mode.md diff --git a/check_events.cpp b/check_events.cpp deleted file mode 100644 index bfeb252..0000000 --- a/check_events.cpp +++ /dev/null @@ -1,5 +0,0 @@ -#include -#include -int main() { std::cout << \ -SDL_EVENT_QUIT: -\ << SDL_EVENT_QUIT << std::endl; return 0; } diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 689f9d5..db0b29e 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -82,6 +82,75 @@ static const std::array COLORS = {{ SDL_Color{255, 160, 0, 255}, // L }}; +static std::string GetLevelStoryText(int level) { + int lvl = std::clamp(level, 1, 100); + + // Milestones + switch (lvl) { + case 1: return "Launch log: training run, light debris ahead."; + case 25: return "Checkpoint: dense field reported, shields ready."; + case 50: return "Midway brief: hull stress rising, stay sharp."; + case 75: return "Emergency corridor: comms unstable, proceed blind."; + case 100: return "Final anomaly: unknown mass ahead, hold course."; + default: break; + } + + struct Pool { int minL, maxL; std::vector lines; }; + static const std::vector pools = { + {1, 10, { + "Departure logged: light debris, stay on vector.", + "Training sector: minimal drift, keep sensors warm.", + "Calm approach: verify thrusters and nav locks.", + "Outer ring dust: watch for slow movers.", + "Clear lanes ahead: focus on smooth rotations." + }}, + {11, 25, { + "Asteroid belt thickening; micro-impacts likely.", + "Density rising: plot short burns only.", + "Field report: medium fragments, unpredictable spin.", + "Warning: overlapping paths, reduce horizontal drift.", + "Rock chorus ahead; keep payload stable." + }}, + {26, 40, { + "Unstable sector: abandoned relays drifting erratic.", + "Salvage echoes detected; debris wakes may tug.", + "Hull groans recorded; inert structures nearby.", + "Navigation buoys dark; trust instruments only.", + "Magnetic static rising; expect odd rotations." + }}, + {41, 60, { + "Core corridor: heavy asteroids, minimal clearance.", + "Impact risk high: armor checks recommended.", + "Dense stone flow; time burns carefully.", + "Grav eddies noted; blocks may drift late.", + "Core shards are brittle; expect sudden splits." + }}, + {61, 80, { + "Critical zone: alarms pinned, route unstable.", + "Emergency pattern: glide, then cut thrust.", + "Sensors flare; debris ionized, visibility low.", + "Thermals spiking; keep pieces tight and fast.", + "Silent channel; assume worst-case collision." + }}, + {81, 100, { + "Unknown space: signals warp, gravity unreliable.", + "Anomaly bloom ahead; shapes flicker unpredictably.", + "Final drift: void sings through hull plates.", + "Black sector: map useless, fly by instinct.", + "Edge of chart: nothing responds, just move." + }} + }; + + for (const auto& pool : pools) { + if (lvl >= pool.minL && lvl <= pool.maxL && !pool.lines.empty()) { + size_t idx = static_cast((lvl - pool.minL) % pool.lines.size()); + return pool.lines[idx]; + } + } + + return "Mission log update unavailable."; +} + struct TetrisApp::Impl { // Global collector for asset loading errors shown on the loading screen std::vector assetLoadErrors; @@ -137,6 +206,7 @@ struct TetrisApp::Impl { int mainScreenH = 0; SDL_Texture* blocksTex = nullptr; + SDL_Texture* asteroidsTex = nullptr; SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr; @@ -163,6 +233,7 @@ struct TetrisApp::Impl { std::vector tripleSounds; std::vector tetrisSounds; bool suppressLineVoiceForLevelUp = false; + bool skipNextLevelUpJingle = false; AppState state = AppState::Loading; double loadingProgress = 0.0; @@ -184,13 +255,32 @@ struct TetrisApp::Impl { float menuFadeAlpha = 0.0f; double MENU_PLAY_FADE_DURATION_MS = 450.0; AppState menuFadeTarget = AppState::Menu; + + enum class CountdownSource { MenuStart, ChallengeLevel }; bool menuPlayCountdownArmed = false; bool gameplayCountdownActive = false; double gameplayCountdownElapsed = 0.0; int gameplayCountdownIndex = 0; double GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; std::array GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; + CountdownSource gameplayCountdownSource = CountdownSource::MenuStart; + int countdownLevel = 0; + int countdownGoalAsteroids = 0; + bool countdownAdvancesChallenge = false; + bool challengeCountdownWaitingForSpace = false; double gameplayBackgroundClockMs = 0.0; + std::string challengeStoryText; + int challengeStoryLevel = 0; + float challengeStoryAlpha = 0.0f; + double challengeStoryClockMs = 0.0; + + // Challenge clear FX (celebratory board explosion before countdown) + bool challengeClearFxActive = false; + double challengeClearFxElapsedMs = 0.0; + double challengeClearFxDurationMs = 0.0; + int challengeClearFxNextLevel = 0; + std::vector challengeClearFxOrder; + std::mt19937 challengeClearFxRng{std::random_device{}()}; std::unique_ptr stateMgr; StateContext ctx{}; @@ -369,11 +459,19 @@ int TetrisApp::Impl::init() }); game->setLevelUpCallback([this](int /*newLevel*/) { - SoundEffectManager::instance().playSound("new_level", 1.0f); - SoundEffectManager::instance().playSound("lets_go", 1.0f); + if (skipNextLevelUpJingle) { + skipNextLevelUpJingle = false; + } else { + SoundEffectManager::instance().playSound("new_level", 1.0f); + SoundEffectManager::instance().playSound("lets_go", 1.0f); + } suppressLineVoiceForLevelUp = true; }); + game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) { + SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f); + }); + state = AppState::Loading; loadingProgress = 0.0; loadStart = SDL_GetTicks(); @@ -419,6 +517,7 @@ int TetrisApp::Impl::init() ctx.logoSmallW = logoSmallW; ctx.logoSmallH = logoSmallH; ctx.backgroundTex = nullptr; + ctx.asteroidsTex = asteroidsTex; ctx.blocksTex = blocksTex; ctx.scorePanelTex = scorePanelTex; ctx.statisticsPanelTex = statisticsPanelTex; @@ -435,6 +534,14 @@ int TetrisApp::Impl::init() ctx.exitPopupSelectedButton = &exitPopupSelectedButton; ctx.gameplayCountdownActive = &gameplayCountdownActive; ctx.menuPlayCountdownArmed = &menuPlayCountdownArmed; + ctx.skipNextLevelUpJingle = &skipNextLevelUpJingle; + ctx.challengeClearFxActive = &challengeClearFxActive; + ctx.challengeClearFxElapsedMs = &challengeClearFxElapsedMs; + ctx.challengeClearFxDurationMs = &challengeClearFxDurationMs; + ctx.challengeClearFxOrder = &challengeClearFxOrder; + ctx.challengeStoryText = &challengeStoryText; + ctx.challengeStoryLevel = &challengeStoryLevel; + ctx.challengeStoryAlpha = &challengeStoryAlpha; ctx.playerName = &playerName; ctx.fullscreenFlag = &isFullscreen; ctx.applyFullscreen = [this](bool enable) { @@ -555,6 +662,52 @@ void TetrisApp::Impl::runLoop() } }; + auto captureChallengeStory = [this](int level) { + int lvl = std::clamp(level, 1, 100); + challengeStoryLevel = lvl; + challengeStoryText = GetLevelStoryText(lvl); + challengeStoryClockMs = 0.0; + challengeStoryAlpha = 0.0f; + }; + + auto startChallengeClearFx = [this](int nextLevel) { + challengeClearFxOrder.clear(); + const auto& boardRef = game->boardRef(); + const auto& asteroidRef = game->asteroidCells(); + for (int idx = 0; idx < Game::COLS * Game::ROWS; ++idx) { + if (boardRef[idx] != 0 || asteroidRef[idx].has_value()) { + challengeClearFxOrder.push_back(idx); + } + } + if (challengeClearFxOrder.empty()) { + challengeClearFxOrder.reserve(Game::COLS * Game::ROWS); + for (int idx = 0; idx < Game::COLS * Game::ROWS; ++idx) { + challengeClearFxOrder.push_back(idx); + } + } + // Seed FX RNG deterministically from the game's challenge seed so animations + // are reproducible per-run and per-level. Fall back to a random seed if game absent. + if (game) { + challengeClearFxRng.seed(game->getChallengeSeedBase() + static_cast(nextLevel)); + } else { + challengeClearFxRng.seed(std::random_device{}()); + } + std::shuffle(challengeClearFxOrder.begin(), challengeClearFxOrder.end(), challengeClearFxRng); + + challengeClearFxElapsedMs = 0.0; + challengeClearFxDurationMs = std::clamp(800.0 + static_cast(challengeClearFxOrder.size()) * 8.0, 900.0, 2600.0); + challengeClearFxNextLevel = nextLevel; + challengeClearFxActive = true; + gameplayCountdownActive = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + menuPlayCountdownArmed = false; + if (game) { + game->setPaused(true); + } + SoundEffectManager::instance().playSound("challenge_clear", 0.8f); + }; + while (running) { if (!ctx.scores && scoresLoadComplete.load(std::memory_order_acquire)) { @@ -703,7 +856,12 @@ void TetrisApp::Impl::runLoop() } } else { if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) { - game->reset(startLevelSelection); + if (game->getMode() == GameMode::Challenge) { + game->startChallengeRun(1); + } else { + game->setMode(GameMode::Endless); + game->reset(startLevelSelection); + } state = AppState::Playing; stateMgr->setState(state); } else if (e.key.scancode == SDL_SCANCODE_ESCAPE) { @@ -732,6 +890,16 @@ void TetrisApp::Impl::runLoop() if (menuInput.activated) { switch (*menuInput.activated) { case ui::BottomMenuItem::Play: + if (game) game->setMode(GameMode::Endless); + startMenuPlayTransition(); + break; + case ui::BottomMenuItem::Challenge: + if (game) { + game->setMode(GameMode::Challenge); + // Suppress the initial level-up jingle when starting Challenge from menu + skipNextLevelUpJingle = true; + game->startChallengeRun(1); + } startMenuPlayTransition(); break; case ui::BottomMenuItem::Level: @@ -841,6 +1009,28 @@ void TetrisApp::Impl::runLoop() } } } + else if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + if (gameplayCountdownActive && gameplayCountdownSource == CountdownSource::ChallengeLevel && challengeCountdownWaitingForSpace) { + if (e.key.scancode == SDL_SCANCODE_SPACE) { + challengeCountdownWaitingForSpace = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + } else if (e.key.scancode == SDL_SCANCODE_ESCAPE) { + // Show quit popup, keep game paused, cancel countdown + if (!showExitConfirmPopup) { + showExitConfirmPopup = true; + exitPopupSelectedButton = 1; // default to NO + } + gameplayCountdownActive = false; + menuPlayCountdownArmed = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + countdownAdvancesChallenge = false; + challengeCountdownWaitingForSpace = false; + if (game) game->setPaused(true); + } + } + } } } @@ -850,6 +1040,69 @@ void TetrisApp::Impl::runLoop() if (frameMs > 100.0) frameMs = 100.0; gameplayBackgroundClockMs += frameMs; + auto clearChallengeStory = [this]() { + challengeStoryText.clear(); + challengeStoryLevel = 0; + challengeStoryAlpha = 0.0f; + challengeStoryClockMs = 0.0; + }; + + // Update challenge story fade/timeout; during countdown wait we keep it fully visible + if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !challengeStoryText.empty()) { + if (gameplayCountdownActive && gameplayCountdownSource == CountdownSource::ChallengeLevel && challengeCountdownWaitingForSpace) { + // Locked-visible while waiting + challengeStoryAlpha = 1.0f; + } else { + const double fadeInMs = 320.0; + const double holdMs = 3200.0; + const double fadeOutMs = 900.0; + const double totalMs = fadeInMs + holdMs + fadeOutMs; + challengeStoryClockMs += frameMs; + if (challengeStoryClockMs >= totalMs) { + clearChallengeStory(); + } else { + double a = 1.0; + if (challengeStoryClockMs < fadeInMs) { + a = challengeStoryClockMs / fadeInMs; + } else if (challengeStoryClockMs > fadeInMs + holdMs) { + double t = challengeStoryClockMs - (fadeInMs + holdMs); + a = std::max(0.0, 1.0 - t / fadeOutMs); + } + challengeStoryAlpha = static_cast(std::clamp(a, 0.0, 1.0)); + } + } + } else { + clearChallengeStory(); + } + + if (challengeClearFxActive) { + challengeClearFxElapsedMs += frameMs; + if (challengeClearFxElapsedMs >= challengeClearFxDurationMs) { + challengeClearFxElapsedMs = challengeClearFxDurationMs; + challengeClearFxActive = false; + if (challengeClearFxNextLevel > 0) { + // Advance to the next challenge level immediately so the countdown shows the new board/asteroids + if (game) { + game->beginNextChallengeLevel(); + game->setPaused(true); + } + gameplayCountdownSource = CountdownSource::ChallengeLevel; + countdownLevel = challengeClearFxNextLevel; + countdownGoalAsteroids = challengeClearFxNextLevel; + captureChallengeStory(countdownLevel); + countdownAdvancesChallenge = false; // already advanced + gameplayCountdownActive = true; + challengeCountdownWaitingForSpace = true; + menuPlayCountdownArmed = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + SoundEffectManager::instance().playSound("new_level", 1.0f); + skipNextLevelUpJingle = true; + } + challengeClearFxNextLevel = 0; + } + } + const bool *ks = SDL_GetKeyboardState(nullptr); bool left = state == AppState::Playing && ks[SDL_SCANCODE_LEFT]; bool right = state == AppState::Playing && ks[SDL_SCANCODE_RIGHT]; @@ -976,7 +1229,8 @@ void TetrisApp::Impl::runLoop() Assets::PANEL_SCORE, Assets::PANEL_STATS, Assets::NEXT_PANEL, - Assets::HOLD_PANEL + Assets::HOLD_PANEL, + Assets::ASTEROID_SPRITE }; for (auto &p : queuedPaths) { loadingManager->queueTexture(p); @@ -986,9 +1240,15 @@ void TetrisApp::Impl::runLoop() SoundEffectManager::instance().init(); loadedTasks.fetch_add(1); - const std::vector audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level"}; + const std::vector audioIds = {"clear_line","nice_combo","you_fire","well_played","keep_that_ryhtm","great_move","smooth_clear","impressive","triple_strike","amazing","you_re_unstoppable","boom_tetris","wonderful","lets_go","hard_drop","new_level","asteroid_destroy","challenge_clear"}; for (const auto &id : audioIds) { - std::string basePath = "assets/music/" + (id == "hard_drop" ? "hard_drop_001" : id); + std::string basePath = "assets/music/" + (id == "hard_drop" + ? "hard_drop_001" + : (id == "challenge_clear" + ? "GONG0" + : (id == "asteroid_destroy" + ? "asteroid-destroy" + : id))); { std::lock_guard lk(currentLoadingMutex); currentLoadingFile = basePath; @@ -1011,6 +1271,7 @@ void TetrisApp::Impl::runLoop() logoSmallTex = assetLoader.getTexture(Assets::LOGO); mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); blocksTex = assetLoader.getTexture(Assets::BLOCKS_SPRITE); + asteroidsTex = assetLoader.getTexture(Assets::ASTEROID_SPRITE); scorePanelTex = assetLoader.getTexture(Assets::PANEL_SCORE); statisticsPanelTex = assetLoader.getTexture(Assets::PANEL_STATS); nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL); @@ -1043,6 +1304,7 @@ void TetrisApp::Impl::runLoop() legacyLoad(Assets::LOGO, logoSmallTex, &logoSmallW, &logoSmallH); legacyLoad(Assets::MAIN_SCREEN, mainScreenTex, &mainScreenW, &mainScreenH); legacyLoad(Assets::BLOCKS_SPRITE, blocksTex); + legacyLoad(Assets::ASTEROID_SPRITE, asteroidsTex); legacyLoad(Assets::PANEL_SCORE, scorePanelTex); legacyLoad(Assets::PANEL_STATS, statisticsPanelTex); legacyLoad(Assets::NEXT_PANEL, nextPanelTex); @@ -1195,12 +1457,20 @@ void TetrisApp::Impl::runLoop() break; } + if (state == AppState::Playing && game && game->getMode() == GameMode::Challenge && !gameplayCountdownActive && !challengeClearFxActive) { + int queuedLevel = game->consumeQueuedChallengeLevel(); + if (queuedLevel > 0) { + startChallengeClearFx(queuedLevel); + } + } + ctx.logoTex = logoTex; ctx.logoSmallTex = logoSmallTex; ctx.logoSmallW = logoSmallW; ctx.logoSmallH = logoSmallH; ctx.backgroundTex = backgroundTex; ctx.blocksTex = blocksTex; + ctx.asteroidsTex = asteroidsTex; ctx.scorePanelTex = scorePanelTex; ctx.statisticsPanelTex = statisticsPanelTex; ctx.nextPanelTex = nextPanelTex; @@ -1219,6 +1489,20 @@ void TetrisApp::Impl::runLoop() } if (menuFadeTarget == AppState::Playing) { + gameplayCountdownSource = (game && game->getMode() == GameMode::Challenge) + ? CountdownSource::ChallengeLevel + : CountdownSource::MenuStart; + countdownLevel = game ? game->challengeLevel() : 1; + countdownGoalAsteroids = countdownLevel; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel) { + captureChallengeStory(countdownLevel); + challengeCountdownWaitingForSpace = true; + } else { + challengeStoryText.clear(); + challengeStoryLevel = 0; + challengeCountdownWaitingForSpace = false; + } + countdownAdvancesChallenge = false; menuPlayCountdownArmed = true; gameplayCountdownActive = false; gameplayCountdownIndex = 0; @@ -1229,6 +1513,7 @@ void TetrisApp::Impl::runLoop() gameplayCountdownActive = false; gameplayCountdownIndex = 0; gameplayCountdownElapsed = 0.0; + challengeCountdownWaitingForSpace = false; game->setPaused(false); } menuFadePhase = MenuFadePhase::FadeIn; @@ -1246,6 +1531,20 @@ void TetrisApp::Impl::runLoop() } if (menuFadePhase == MenuFadePhase::None && menuPlayCountdownArmed && !gameplayCountdownActive && state == AppState::Playing) { + gameplayCountdownSource = (game && game->getMode() == GameMode::Challenge) + ? CountdownSource::ChallengeLevel + : CountdownSource::MenuStart; + countdownLevel = game ? game->challengeLevel() : 1; + countdownGoalAsteroids = countdownLevel; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel) { + captureChallengeStory(countdownLevel); + challengeCountdownWaitingForSpace = true; + } else { + challengeStoryText.clear(); + challengeStoryLevel = 0; + challengeCountdownWaitingForSpace = false; + } + countdownAdvancesChallenge = false; gameplayCountdownActive = true; menuPlayCountdownArmed = false; gameplayCountdownElapsed = 0.0; @@ -1254,15 +1553,21 @@ void TetrisApp::Impl::runLoop() } if (gameplayCountdownActive && state == AppState::Playing) { - gameplayCountdownElapsed += frameMs; - if (gameplayCountdownElapsed >= GAMEPLAY_COUNTDOWN_STEP_MS) { - gameplayCountdownElapsed -= GAMEPLAY_COUNTDOWN_STEP_MS; - ++gameplayCountdownIndex; - if (gameplayCountdownIndex >= static_cast(GAMEPLAY_COUNTDOWN_LABELS.size())) { - gameplayCountdownActive = false; - gameplayCountdownElapsed = 0.0; - gameplayCountdownIndex = 0; - game->setPaused(false); + if (!challengeCountdownWaitingForSpace || gameplayCountdownSource != CountdownSource::ChallengeLevel) { + gameplayCountdownElapsed += frameMs; + if (gameplayCountdownElapsed >= GAMEPLAY_COUNTDOWN_STEP_MS) { + gameplayCountdownElapsed -= GAMEPLAY_COUNTDOWN_STEP_MS; + ++gameplayCountdownIndex; + if (gameplayCountdownIndex >= static_cast(GAMEPLAY_COUNTDOWN_LABELS.size())) { + gameplayCountdownActive = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + if (gameplayCountdownSource == CountdownSource::ChallengeLevel && countdownAdvancesChallenge && game) { + game->beginNextChallengeLevel(); + } + countdownAdvancesChallenge = false; + game->setPaused(false); + } } } } @@ -1272,9 +1577,19 @@ void TetrisApp::Impl::runLoop() menuPlayCountdownArmed = false; gameplayCountdownElapsed = 0.0; gameplayCountdownIndex = 0; + countdownAdvancesChallenge = false; + challengeCountdownWaitingForSpace = false; game->setPaused(false); } + if (state != AppState::Playing && challengeClearFxActive) { + challengeClearFxActive = false; + challengeClearFxElapsedMs = 0.0; + challengeClearFxDurationMs = 0.0; + challengeClearFxNextLevel = 0; + challengeClearFxOrder.clear(); + } + SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_RenderClear(renderer); @@ -1507,10 +1822,12 @@ void TetrisApp::Impl::runLoop() &pixelFont, &lineEffect, blocksTex, + asteroidsTex, ctx.statisticsPanelTex, scorePanelTex, nextPanelTex, holdPanelTex, + false, (float)LOGICAL_W, (float)LOGICAL_H, logicalScale, @@ -1661,8 +1978,101 @@ void TetrisApp::Impl::runLoop() float textX = (winW - static_cast(textW)) * 0.5f; float textY = (winH - static_cast(textH)) * 0.5f; - SDL_Color textColor = isFinalCue ? SDL_Color{255, 230, 90, 255} : SDL_Color{255, 255, 255, 255}; - pixelFont.draw(renderer, textX, textY, label, textScale, textColor); + if (gameplayCountdownSource == CountdownSource::ChallengeLevel) { + char levelBuf[32]; + std::snprintf(levelBuf, sizeof(levelBuf), "LEVEL %d", countdownLevel); + int lvlW = 0, lvlH = 0; + float lvlScale = 2.5f; + pixelFont.measure(levelBuf, lvlScale, lvlW, lvlH); + float levelX = (winW - static_cast(lvlW)) * 0.5f; + float levelY = winH * 0.32f; + pixelFont.draw(renderer, levelX, levelY, levelBuf, lvlScale, SDL_Color{140, 210, 255, 255}); + + char goalBuf[64]; + std::snprintf(goalBuf, sizeof(goalBuf), "ASTEROIDS: %d", countdownGoalAsteroids); + int goalW = 0, goalH = 0; + float goalScale = 1.7f; + pixelFont.measure(goalBuf, goalScale, goalW, goalH); + float goalX = (winW - static_cast(goalW)) * 0.5f; + float goalY = levelY + static_cast(lvlH) + 14.0f; + pixelFont.draw(renderer, goalX, goalY, goalBuf, goalScale, SDL_Color{220, 245, 255, 255}); + + // Optional story/briefing line + if (!challengeStoryText.empty() && challengeStoryAlpha > 0.0f) { + SDL_Color storyColor{170, 230, 255, static_cast(std::lround(255.0f * challengeStoryAlpha))}; + SDL_Color shadowColor{0, 0, 0, static_cast(std::lround(160.0f * challengeStoryAlpha))}; + + auto drawCenteredWrapped = [&](const std::string& text, float y, float maxWidth, float scale) { + std::istringstream iss(text); + std::string word; + std::string line; + float cursorY = y; + int lastH = 0; + while (iss >> word) { + std::string candidate = line.empty() ? word : (line + " " + word); + int candidateW = 0, candidateH = 0; + pixelFont.measure(candidate, scale, candidateW, candidateH); + if (candidateW > maxWidth && !line.empty()) { + int lineW = 0, lineH = 0; + pixelFont.measure(line, scale, lineW, lineH); + float lineX = (winW - static_cast(lineW)) * 0.5f; + pixelFont.draw(renderer, lineX + 1.0f, cursorY + 1.0f, line, scale, shadowColor); + pixelFont.draw(renderer, lineX, cursorY, line, scale, storyColor); + cursorY += lineH + 6.0f; + line = word; + lastH = lineH; + } else { + line = candidate; + lastH = candidateH; + } + } + if (!line.empty()) { + int w = 0, h = 0; + pixelFont.measure(line, scale, w, h); + float lineX = (winW - static_cast(w)) * 0.5f; + pixelFont.draw(renderer, lineX + 1.0f, cursorY + 1.0f, line, scale, shadowColor); + pixelFont.draw(renderer, lineX, cursorY, line, scale, storyColor); + cursorY += h + 6.0f; + } + return cursorY; + }; + + float storyStartY = goalY + static_cast(goalH) + 22.0f; + float usedY = drawCenteredWrapped(challengeStoryText, storyStartY, std::min(winW * 0.7f, 720.0f), 1.0f); + float promptY = usedY + 10.0f; + if (challengeCountdownWaitingForSpace) { + const char* prompt = "PRESS SPACE"; + int pW = 0, pH = 0; + float pScale = 1.35f; + pixelFont.measure(prompt, pScale, pW, pH); + float px = (winW - static_cast(pW)) * 0.5f; + pixelFont.draw(renderer, px + 2.0f, promptY + 2.0f, prompt, pScale, SDL_Color{0, 0, 0, 200}); + pixelFont.draw(renderer, px, promptY, prompt, pScale, SDL_Color{255, 220, 40, 255}); + promptY += pH + 14.0f; + } + textY = promptY + 10.0f; + } else { + if (challengeCountdownWaitingForSpace) { + const char* prompt = "PRESS SPACE"; + int pW = 0, pH = 0; + float pScale = 1.35f; + pixelFont.measure(prompt, pScale, pW, pH); + float px = (winW - static_cast(pW)) * 0.5f; + float py = goalY + static_cast(goalH) + 18.0f; + pixelFont.draw(renderer, px + 2.0f, py + 2.0f, prompt, pScale, SDL_Color{0, 0, 0, 200}); + pixelFont.draw(renderer, px, py, prompt, pScale, SDL_Color{255, 220, 40, 255}); + textY = py + pH + 24.0f; + } else { + textY = goalY + static_cast(goalH) + 38.0f; + } + } + } else { + textY = winH * 0.38f; + } + if (!(gameplayCountdownSource == CountdownSource::ChallengeLevel && challengeCountdownWaitingForSpace)) { + SDL_Color textColor = isFinalCue ? SDL_Color{255, 230, 90, 255} : SDL_Color{255, 255, 255, 255}; + pixelFont.draw(renderer, textX, textY, label, textScale, textColor); + } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } diff --git a/src/audio/SoundEffect.cpp b/src/audio/SoundEffect.cpp index 2a34b66..ecc34ee 100644 --- a/src/audio/SoundEffect.cpp +++ b/src/audio/SoundEffect.cpp @@ -46,7 +46,6 @@ bool SoundEffect::load(const std::string& filePath) { } loaded = true; - //std::printf("[SoundEffect] Loaded: %s (%d channels, %d Hz, %zu samples)\n", filePath.c_str(), channels, sampleRate, pcmData.size()); return true; } @@ -55,9 +54,7 @@ void SoundEffect::play(float volume) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] Cannot play - loaded=%d, pcmData.size()=%zu", loaded, pcmData.size()); return; } - - //std::printf("[SoundEffect] Playing sound with %zu samples at volume %.2f\n", pcmData.size(), volume); - + // Calculate final volume float finalVolume = defaultVolume * volume; finalVolume = (std::max)(0.0f, (std::min)(1.0f, finalVolume)); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index bd22541..194b87b 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -643,6 +643,7 @@ bool ApplicationManager::initializeGame() { } else { m_stateContext.logoSmallW = 0; m_stateContext.logoSmallH = 0; } m_stateContext.backgroundTex = m_assetManager->getTexture("background"); m_stateContext.blocksTex = m_assetManager->getTexture("blocks"); + m_stateContext.asteroidsTex = m_assetManager->getTexture("asteroids"); m_stateContext.musicEnabled = &m_musicEnabled; m_stateContext.musicStarted = &m_musicStarted; m_stateContext.musicLoaded = &m_musicLoaded; @@ -1162,10 +1163,12 @@ void ApplicationManager::setupStateHandlers() { m_stateContext.pixelFont, m_stateContext.lineEffect, m_stateContext.blocksTex, + m_stateContext.asteroidsTex, m_stateContext.statisticsPanelTex, m_stateContext.scorePanelTex, m_stateContext.nextPanelTex, m_stateContext.holdPanelTex, + false, LOGICAL_W, LOGICAL_H, logicalScale, diff --git a/src/gameplay/core/Game.cpp b/src/gameplay/core/Game.cpp index 10cd22d..8a560a0 100644 --- a/src/gameplay/core/Game.cpp +++ b/src/gameplay/core/Game.cpp @@ -51,7 +51,11 @@ namespace { } void Game::reset(int startLevel_) { + // Standard reset is primarily for endless; Challenge reuses the same pipeline and then + // immediately sets up its own level state. std::fill(board.begin(), board.end(), 0); + clearAsteroidGrid(); + recentAsteroidExplosions.clear(); std::fill(blockCounts.begin(), blockCounts.end(), 0); bag.clear(); _score = 0; _lines = 0; _level = startLevel_; startLevel = startLevel_; @@ -59,6 +63,10 @@ void Game::reset(int startLevel_) { _currentCombo = 0; _maxCombo = 0; _comboCount = 0; + challengeComplete = false; + challengeLevelActive = false; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; // Initialize gravity using NES timing table (ms per cell by level) gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier); fallAcc = 0; gameOver=false; paused=false; @@ -69,9 +77,232 @@ void Game::reset(int startLevel_) { _pausedTime = 0; _lastPauseStart = 0; hold = Piece{}; hold.type = PIECE_COUNT; canHold=true; - refillBag(); - pieceSequence = 0; - spawn(); + refillBag(); + pieceSequence = 0; + spawn(); + + if (mode == GameMode::Challenge) { + int lvl = startLevel_ <= 0 ? 1 : startLevel_; + startChallengeRun(lvl); + } +} + +void Game::clearAsteroidGrid() { + for (auto &cell : asteroidGrid) { + cell.reset(); + } + asteroidsRemainingCount = 0; + asteroidsTotalThisLevel = 0; +} + +void Game::startChallengeRun(int startingLevel) { + mode = GameMode::Challenge; + int lvl = std::clamp(startingLevel, 1, ASTEROID_MAX_LEVEL); + // Reset all stats and timers like a fresh run + _score = 0; _lines = 0; _level = lvl; startLevel = lvl; + _tetrisesMade = 0; + _currentCombo = 0; + _maxCombo = 0; + _comboCount = 0; + _startTime = SDL_GetPerformanceCounter(); + _pausedTime = 0; + _lastPauseStart = 0; + // Reseed challenge RNG so levels are deterministic per run but distinct per session + if (challengeSeedBase == 0) { + challengeSeedBase = static_cast(SDL_GetTicks()); + } + challengeRng.seed(challengeSeedBase + static_cast(lvl)); + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + setupChallengeLevel(lvl, false); +} + +void Game::beginNextChallengeLevel() { + if (mode != GameMode::Challenge || challengeComplete) { + return; + } + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + int next = challengeLevelIndex + 1; + if (next > ASTEROID_MAX_LEVEL) { + challengeComplete = true; + challengeLevelActive = false; + return; + } + setupChallengeLevel(next, true); +} + +void Game::setupChallengeLevel(int level, bool preserveStats) { + challengeLevelIndex = std::clamp(level, 1, ASTEROID_MAX_LEVEL); + _level = challengeLevelIndex; + startLevel = challengeLevelIndex; + challengeComplete = false; + challengeLevelActive = true; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + // Refresh deterministic RNG for this level + challengeRng.seed(challengeSeedBase + static_cast(challengeLevelIndex)); + + // Optionally reset cumulative stats (new run) or keep them (between levels) + if (!preserveStats) { + std::fill(blockCounts.begin(), blockCounts.end(), 0); + _score = 0; + _lines = 0; + _tetrisesMade = 0; + _currentCombo = 0; + _comboCount = 0; + _maxCombo = 0; + _startTime = SDL_GetPerformanceCounter(); + _pausedTime = 0; + _lastPauseStart = 0; + } else { + _currentCombo = 0; + } + + // Clear playfield and piece state + std::fill(board.begin(), board.end(), 0); + clearAsteroidGrid(); + completedLines.clear(); + hardDropCells.clear(); + hardDropFxId = 0; + recentAsteroidExplosions.clear(); + fallAcc = 0.0; + gameOver = false; + paused = false; + softDropping = false; + hold = Piece{}; + hold.type = PIECE_COUNT; + canHold = true; + bag.clear(); + refillBag(); + pieceSequence = 0; + spawn(); + + // Challenge gravity scales upward per level (faster = smaller ms per cell) + double baseMs = gravityMsForLevel(0, gravityGlobalMultiplier); + double speedFactor = 1.0 + static_cast(challengeLevelIndex) * 0.02; + gravityMs = (speedFactor > 0.0) ? (baseMs / speedFactor) : baseMs; + + // Place asteroids for this level + placeAsteroidsForLevel(challengeLevelIndex); + + if (levelUpCallback) { + levelUpCallback(_level); + } +} + +AsteroidType Game::chooseAsteroidTypeForLevel(int level) { + // Simple weight distribution by level bands + int normalWeight = 100; + int armoredWeight = 0; + int fallingWeight = 0; + int coreWeight = 0; + + if (level >= 10) { + armoredWeight = 20; + normalWeight = 80; + } + if (level >= 20) { + fallingWeight = 20; + normalWeight = 60; + } + if (level >= 40) { + fallingWeight = 30; + armoredWeight = 25; + normalWeight = 45; + } + if (level >= 60) { + coreWeight = 20; + fallingWeight = 30; + armoredWeight = 25; + normalWeight = 25; + } + + int total = normalWeight + armoredWeight + fallingWeight + coreWeight; + if (total <= 0) return AsteroidType::Normal; + std::uniform_int_distribution dist(0, total - 1); + int pick = dist(challengeRng); + if (pick < normalWeight) return AsteroidType::Normal; + pick -= normalWeight; + if (pick < armoredWeight) return AsteroidType::Armored; + pick -= armoredWeight; + if (pick < fallingWeight) return AsteroidType::Falling; + return AsteroidType::Core; +} + +AsteroidCell Game::makeAsteroidForType(AsteroidType t) const { + AsteroidCell cell{}; + cell.type = t; + switch (t) { + case AsteroidType::Normal: + cell.hitsRemaining = 1; + cell.gravityEnabled = false; + break; + case AsteroidType::Armored: + cell.hitsRemaining = 2; + cell.gravityEnabled = false; + break; + case AsteroidType::Falling: + cell.hitsRemaining = 2; + cell.gravityEnabled = false; + break; + case AsteroidType::Core: + cell.hitsRemaining = 3; + cell.gravityEnabled = false; + break; + } + cell.visualState = 0; + return cell; +} + +void Game::placeAsteroidsForLevel(int level) { + int desired = std::clamp(level, 1, ASTEROID_MAX_LEVEL); + // Placement window grows upward with level but caps at half board + int height = std::clamp(2 + level / 3, 2, ROWS / 2); + int minRow = ROWS - 1 - height; + int maxRow = ROWS - 1; + minRow = std::max(0, minRow); + + std::uniform_int_distribution xDist(0, COLS - 1); + std::uniform_int_distribution yDist(minRow, maxRow); + + int attempts = 0; + const int maxAttempts = desired * 16; + while (asteroidsRemainingCount < desired && attempts < maxAttempts) { + int x = xDist(challengeRng); + int y = yDist(challengeRng); + int idx = y * COLS + x; + attempts++; + if (board[idx] != 0 || asteroidGrid[idx].has_value()) { + continue; + } + AsteroidType type = chooseAsteroidTypeForLevel(level); + AsteroidCell cell = makeAsteroidForType(type); + board[idx] = asteroidBoardValue(type); + asteroidGrid[idx] = cell; + ++asteroidsRemainingCount; + ++asteroidsTotalThisLevel; + } + + if (asteroidsRemainingCount < desired) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[CHALLENGE] Placed %d/%d asteroids for level %d", asteroidsRemainingCount, desired, level); + } +} + +// Helper implementations for asteroid board encoding +bool Game::isAsteroidValue(int boardValue) { + return boardValue >= ASTEROID_BASE; +} + +AsteroidType Game::asteroidTypeFromValue(int boardValue) { + int idx = boardValue - ASTEROID_BASE; + if (idx < 0) return AsteroidType::Normal; + if (idx > static_cast(AsteroidType::Core)) idx = static_cast(AsteroidType::Core); + return static_cast(idx); +} + +int Game::asteroidBoardValue(AsteroidType t) { + return ASTEROID_BASE + static_cast(t); } double Game::elapsed() const { @@ -113,6 +344,16 @@ void Game::setPaused(bool p) { paused = p; } +int Game::consumeQueuedChallengeLevel() { + if (!challengeAdvanceQueued) { + return 0; + } + int next = challengeQueuedLevel; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + return next; +} + void Game::setSoftDropping(bool on) { if (softDropping == on) { return; @@ -235,23 +476,28 @@ void Game::lockPiece() { _tetrisesMade += 1; } - // JS level progression (NES-like) using starting level rules - // Both startLevel and _level are 0-based now. - int targetLevel = startLevel; - int firstThreshold = (startLevel + 1) * 10; - - if (_lines >= firstThreshold) { - targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10; - } - - // If we haven't reached the first threshold yet, we are still at startLevel. - // The above logic handles this (targetLevel initialized to startLevel). + if (mode != GameMode::Challenge) { + // JS level progression (NES-like) using starting level rules + // Both startLevel and _level are 0-based now. + int targetLevel = startLevel; + int firstThreshold = (startLevel + 1) * 10; - if (targetLevel > _level) { - _level = targetLevel; - // Update gravity to exact NES speed for the new level - gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier); - if (levelUpCallback) levelUpCallback(_level); + if (_lines >= firstThreshold) { + targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10; + } + + // If we haven't reached the first threshold yet, we are still at startLevel. + // The above logic handles this (targetLevel initialized to startLevel). + + if (targetLevel > _level) { + _level = targetLevel; + // Update gravity to exact NES speed for the new level + gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier); + if (levelUpCallback) levelUpCallback(_level); + } + } else { + // Challenge keeps level tied to the current challenge stage; gravity already set there + _level = challengeLevelIndex; } // Trigger sound effect callback for line clears @@ -282,6 +528,28 @@ int Game::checkLines() { completedLines.push_back(y); } } + + // Pre-play asteroid destroy SFX immediately when a clearing line contains asteroids (reduces latency) + if (!completedLines.empty() && mode == GameMode::Challenge) { + std::optional foundType; + for (int y : completedLines) { + for (int x = 0; x < COLS; ++x) { + int idx = y * COLS + x; + if (isAsteroidValue(board[idx])) { + foundType = asteroidTypeFromValue(board[idx]); + } else if (idx >= 0 && idx < static_cast(asteroidGrid.size()) && asteroidGrid[idx].has_value()) { + foundType = asteroidGrid[idx]->type; + } + } + } + if (foundType.has_value()) { + pendingAsteroidDestroyType = foundType; + if (!asteroidDestroySoundPreplayed && asteroidDestroyedCallback) { + asteroidDestroySoundPreplayed = true; + asteroidDestroyedCallback(*foundType); + } + } + } return static_cast(completedLines.size()); } @@ -295,30 +563,151 @@ void Game::clearCompletedLines() { void Game::actualClearLines() { if (completedLines.empty()) return; - - int write = ROWS - 1; + recentAsteroidExplosions.clear(); + + std::array newBoard{}; + std::array, COLS*ROWS> newAst{}; + for (auto &cell : newAst) cell.reset(); + std::fill(newBoard.begin(), newBoard.end(), 0); + + handleAsteroidsOnClearedRows(completedLines, newBoard, newAst); + + board = newBoard; + asteroidGrid = newAst; + + // Apply asteroid-specific gravity after the board collapses + applyAsteroidGravity(); + + // Reset preplay latch so future destroys can fire again + pendingAsteroidDestroyType.reset(); + asteroidDestroySoundPreplayed = false; + + if (mode == GameMode::Challenge) { + if (asteroidsRemainingCount <= 0) { + int nextLevel = challengeLevelIndex + 1; + if (nextLevel > ASTEROID_MAX_LEVEL) { + challengeComplete = true; + challengeLevelActive = false; + challengeAdvanceQueued = false; + challengeQueuedLevel = 0; + } else { + challengeAdvanceQueued = true; + challengeQueuedLevel = nextLevel; + challengeLevelActive = false; + setPaused(true); + } + } + } +} + +void Game::handleAsteroidsOnClearedRows(const std::vector& clearedRows, + std::array& outBoard, + std::array, COLS*ROWS>& outAsteroids) { + std::vector clearedFlags(ROWS, false); + for (int r : clearedRows) { + if (r >= 0 && r < ROWS) { + clearedFlags[r] = true; + } + } + + // Track asteroid count updates during processing + int destroyedThisPass = 0; + std::optional lastDestroyedType; + + // Precompute how many cleared rows are at or below each row to reposition survivors + std::array clearedBelow{}; + int running = 0; for (int y = ROWS - 1; y >= 0; --y) { - // Check if this row should be cleared - bool shouldClear = std::find(completedLines.begin(), completedLines.end(), y) != completedLines.end(); - - if (!shouldClear) { - // Keep this row, move it down if necessary - if (write != y) { - for (int x = 0; x < COLS; ++x) { - board[write*COLS + x] = board[y*COLS + x]; + clearedBelow[y] = running; + if (clearedFlags[y]) { + ++running; + } + } + + for (int y = ROWS - 1; y >= 0; --y) { + for (int x = 0; x < COLS; ++x) { + int srcIdx = y * COLS + x; + bool rowCleared = clearedFlags[y]; + bool isAsteroid = asteroidGrid[srcIdx].has_value(); + + if (rowCleared) { + if (!isAsteroid) { + continue; // normal blocks in cleared rows vanish + } + + AsteroidCell cell = *asteroidGrid[srcIdx]; + if (cell.hitsRemaining > 0) { + --cell.hitsRemaining; + } + if (cell.hitsRemaining == 0) { + destroyedThisPass++; + lastDestroyedType = cell.type; + continue; + } + + // Update visual/gravity state for surviving asteroids + cell.visualState = static_cast(std::min(3, cell.visualState + 1)); + if (cell.type == AsteroidType::Falling || cell.type == AsteroidType::Core) { + cell.gravityEnabled = true; + } + + int destY = y + clearedBelow[y]; // shift down by cleared rows below + if (destY >= ROWS) { + continue; // off the board after collapse + } + int destIdx = destY * COLS + x; + outBoard[destIdx] = asteroidBoardValue(cell.type); + outAsteroids[destIdx] = cell; + } else { + int destY = y + clearedBelow[y]; + if (destY >= ROWS) { + continue; + } + int destIdx = destY * COLS + x; + outBoard[destIdx] = board[srcIdx]; + if (isAsteroid) { + outAsteroids[destIdx] = asteroidGrid[srcIdx]; } } - --write; - } - // If shouldClear is true, we skip this row (effectively removing it) - } - - // Clear the top rows that are now empty - for (int y = write; y >= 0; --y) { - for (int x = 0; x < COLS; ++x) { - board[y*COLS + x] = 0; } } + + if (destroyedThisPass > 0) { + asteroidsRemainingCount = std::max(0, asteroidsRemainingCount - destroyedThisPass); + if (!asteroidDestroySoundPreplayed && asteroidDestroyedCallback && lastDestroyedType.has_value()) { + asteroidDestroyedCallback(*lastDestroyedType); + } + } +} + +void Game::applyAsteroidGravity() { + if (asteroidsRemainingCount <= 0) { + return; + } + bool moved = false; + do { + moved = false; + for (int y = ROWS - 2; y >= 0; --y) { + for (int x = 0; x < COLS; ++x) { + int idx = y * COLS + x; + if (!asteroidGrid[idx].has_value()) { + continue; + } + if (!asteroidGrid[idx]->gravityEnabled) { + continue; + } + int belowIdx = (y + 1) * COLS + x; + if (board[belowIdx] == 0) { + // Move asteroid down one cell + board[belowIdx] = board[idx]; + asteroidGrid[belowIdx] = asteroidGrid[idx]; + board[idx] = 0; + asteroidGrid[idx].reset(); + moved = true; + } + } + } + } while (moved); } bool Game::tryMoveDown() { diff --git a/src/gameplay/core/Game.h b/src/gameplay/core/Game.h index 5f36ffe..4899f06 100644 --- a/src/gameplay/core/Game.h +++ b/src/gameplay/core/Game.h @@ -7,12 +7,26 @@ #include #include #include +#include #include #include "../../core/GravityManager.h" enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT }; using Shape = std::array; // four rotation bitmasks +// Game runtime mode +enum class GameMode { Endless, Challenge }; + +// Special obstacle blocks used by Challenge mode +enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 }; + +struct AsteroidCell { + AsteroidType type{AsteroidType::Normal}; + uint8_t hitsRemaining{1}; + bool gravityEnabled{false}; + uint8_t visualState{0}; +}; + class Game { public: static constexpr int COLS = 10; @@ -21,8 +35,10 @@ public: struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{3}; int y{-2}; }; - explicit Game(int startLevel = 0) { reset(startLevel); } + explicit Game(int startLevel = 0, GameMode mode = GameMode::Endless) : mode(mode) { reset(startLevel); } void reset(int startLevel = 0); + void startChallengeRun(int startingLevel = 1); // resets stats and starts challenge level 1 (or provided) + void beginNextChallengeLevel(); // advances to the next challenge level preserving score/time // Simulation ----------------------------------------------------------- void tickGravity(double frameMs); // advance gravity accumulator & drop @@ -42,13 +58,26 @@ public: bool isGameOver() const { return gameOver; } bool isPaused() const { return paused; } void setPaused(bool p); + GameMode getMode() const { return mode; } + void setMode(GameMode m) { mode = m; } int score() const { return _score; } int lines() const { return _lines; } int level() const { return _level; } + int challengeLevel() const { return challengeLevelIndex; } + int asteroidsRemaining() const { return asteroidsRemainingCount; } + int asteroidsTotal() const { return asteroidsTotalThisLevel; } + bool isChallengeComplete() const { return challengeComplete; } + bool isChallengeLevelActive() const { return challengeLevelActive; } + bool isChallengeAdvanceQueued() const { return challengeAdvanceQueued; } + int queuedChallengeLevel() const { return challengeQueuedLevel; } + int consumeQueuedChallengeLevel(); // returns next level if queued, else 0 int startLevelBase() const { return startLevel; } double elapsed() const; // Now calculated from start time void updateElapsedTime(); // Update elapsed time from system clock bool isSoftDropping() const { return softDropping; } + const std::array, COLS*ROWS>& asteroidCells() const { return asteroidGrid; } + const std::vector& getRecentAsteroidExplosions() const { return recentAsteroidExplosions; } + void clearRecentAsteroidExplosions() { recentAsteroidExplosions.clear(); } // Block statistics const std::array& getBlockCounts() const { return blockCounts; } @@ -61,8 +90,10 @@ public: // Sound effect callbacks using SoundCallback = std::function; // Callback for line clear sounds (number of lines) using LevelUpCallback = std::function; // Callback for level up sounds + using AsteroidDestroyedCallback = std::function; // Callback when an asteroid is fully destroyed void setSoundCallback(SoundCallback callback) { soundCallback = callback; } void setLevelUpCallback(LevelUpCallback callback) { levelUpCallback = callback; } + void setAsteroidDestroyedCallback(AsteroidDestroyedCallback callback) { asteroidDestroyedCallback = callback; } // Shape helper -------------------------------------------------------- static bool cellFilled(const Piece& p, int cx, int cy); @@ -87,6 +118,9 @@ public: int comboCount() const { return _comboCount; } private: + static constexpr int ASTEROID_BASE = 100; // sentinel offset for board encoding + static constexpr int ASTEROID_MAX_LEVEL = 100; + std::array board{}; // 0 empty else color index Piece cur{}, hold{}, nextPiece{}; // current, held & next piece bool canHold{true}; @@ -117,6 +151,7 @@ private: // Sound effect callbacks SoundCallback soundCallback; LevelUpCallback levelUpCallback; + AsteroidDestroyedCallback asteroidDestroyedCallback; // Gravity tuning ----------------------------------------------------- // Global multiplier applied to all level timings (use to slow/speed whole-game gravity) double gravityGlobalMultiplier{1.0}; @@ -132,6 +167,34 @@ private: uint32_t hardDropFxId{0}; uint64_t pieceSequence{0}; + // Challenge mode state ------------------------------------------------- + GameMode mode{GameMode::Endless}; + int challengeLevelIndex{1}; + int asteroidsRemainingCount{0}; + int asteroidsTotalThisLevel{0}; + bool challengeComplete{false}; + std::array, COLS*ROWS> asteroidGrid{}; + uint32_t challengeSeedBase{0}; + std::mt19937 challengeRng{ std::random_device{}() }; + bool challengeLevelActive{false}; + bool challengeAdvanceQueued{false}; + int challengeQueuedLevel{0}; + // Asteroid SFX latency mitigation + std::optional pendingAsteroidDestroyType; + bool asteroidDestroySoundPreplayed{false}; + + // Recent asteroid explosion positions (grid coords) for renderer FX + std::vector recentAsteroidExplosions; + + // Expose the internal challenge seed base for deterministic FX/RNG coordination +public: + uint32_t getChallengeSeedBase() const { return challengeSeedBase; } + + // Helpers for board encoding of asteroids + static bool isAsteroidValue(int boardValue); + static AsteroidType asteroidTypeFromValue(int boardValue); + static int asteroidBoardValue(AsteroidType t); + // Internal helpers ---------------------------------------------------- void refillBag(); void spawn(); @@ -140,5 +203,14 @@ private: int checkLines(); // Find completed lines and store them void actualClearLines(); // Actually remove lines from board bool tryMoveDown(); // one-row fall; returns true if moved + void clearAsteroidGrid(); + void setupChallengeLevel(int level, bool preserveStats); + void placeAsteroidsForLevel(int level); + AsteroidType chooseAsteroidTypeForLevel(int level); + AsteroidCell makeAsteroidForType(AsteroidType t) const; + void handleAsteroidsOnClearedRows(const std::vector& clearedRows, + std::array& outBoard, + std::array, COLS*ROWS>& outAsteroids); + void applyAsteroidGravity(); // Gravity tuning helpers (public API declared above) }; diff --git a/src/graphics/renderers/GameRenderer.cpp b/src/graphics/renderers/GameRenderer.cpp index f1feabd..d549a7e 100644 --- a/src/graphics/renderers/GameRenderer.cpp +++ b/src/graphics/renderers/GameRenderer.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -49,6 +51,31 @@ Starfield3D s_inGridStarfield; bool s_starfieldInitialized = false; std::vector s_sparkles; float s_sparkleSpawnAcc = 0.0f; + +struct AsteroidBurst { + float x; + float y; + float lifeMs; + float maxLifeMs; + float baseRadius; + SDL_Color color; + float spin; +}; + +std::vector s_asteroidBursts; + +struct AsteroidShard { + float x; + float y; + float vx; + float vy; + float lifeMs; + float maxLifeMs; + float size; + SDL_Color color; +}; + +std::vector s_asteroidShards; } struct TransportEffectState { @@ -282,6 +309,62 @@ void GameRenderer::drawRect(SDL_Renderer* renderer, float x, float y, float w, f SDL_RenderFillRect(renderer, &fr); } +static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float x, float y, float size, const AsteroidCell& cell) { + auto outlineGravity = [&](float inset, SDL_Color color) { + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + SDL_FRect glow{ x + inset, y + inset, size - inset * 2.0f, size - inset * 2.0f }; + SDL_RenderRect(renderer, &glow); + }; + + if (asteroidTex) { + const float SPRITE_SIZE = 90.0f; + int col = 0; + switch (cell.type) { + case AsteroidType::Normal: col = 0; break; + case AsteroidType::Armored: col = 1; break; + case AsteroidType::Falling: col = 2; break; + case AsteroidType::Core: col = 3; break; + } + int row = std::clamp(cell.visualState, 0, 2); + SDL_FRect src{ col * SPRITE_SIZE, row * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE }; + SDL_FRect dst{ x, y, size, size }; + SDL_RenderTexture(renderer, asteroidTex, &src, &dst); + + if (cell.gravityEnabled) { + outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); + } + return; + } + + // Fallback: draw a colored quad (previous implementation) + SDL_Color base{}; + switch (cell.type) { + case AsteroidType::Normal: base = SDL_Color{172, 138, 104, 255}; break; + case AsteroidType::Armored: base = SDL_Color{130, 150, 176, 255}; break; + case AsteroidType::Falling: base = SDL_Color{210, 120, 82, 255}; break; + case AsteroidType::Core: base = SDL_Color{198, 78, 200, 255}; break; + } + float hpScale = std::clamp(static_cast(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f); + SDL_Color fill{ + static_cast(base.r * hpScale + 40 * (1.0f - hpScale)), + static_cast(base.g * hpScale + 40 * (1.0f - hpScale)), + static_cast(base.b * hpScale + 40 * (1.0f - hpScale)), + 255 + }; + SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); + SDL_FRect body{x, y, size - 1.0f, size - 1.0f}; + SDL_RenderFillRect(renderer, &body); + + SDL_Color outline = base; + outline.a = 220; + SDL_FRect border{x + 1.0f, y + 1.0f, size - 2.0f, size - 2.0f}; + SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a); + SDL_RenderRect(renderer, &border); + if (cell.gravityEnabled) { + outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); + } +} + void GameRenderer::drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) { if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) { // Fallback to colored rectangle if texture isn't available @@ -515,15 +598,23 @@ void GameRenderer::renderPlayingState( FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* asteroidsTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, SDL_Texture* holdPanelTex, + bool countdownActive, float logicalW, float logicalH, float logicalScale, float winW, - float winH + float winH, + bool challengeClearFxActive, + const std::vector* challengeClearFxOrder, + double challengeClearFxElapsedMs, + double challengeClearFxDurationMs, + const std::string* challengeStoryText, + float challengeStoryAlpha ) { if (!game || !pixelFont) return; @@ -865,8 +956,67 @@ void GameRenderer::renderPlayingState( rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f); } + // Spawn glamour bursts for freshly destroyed asteroids + if (game) { + const auto& bursts = game->getRecentAsteroidExplosions(); + if (!bursts.empty()) { + std::uniform_real_distribution lifeDist(280.0f, 420.0f); + std::uniform_real_distribution radiusDist(finalBlockSize * 0.35f, finalBlockSize * 0.7f); + std::uniform_real_distribution spinDist(-4.0f, 4.0f); + std::uniform_real_distribution shardLife(240.0f, 520.0f); + std::uniform_real_distribution shardVX(-0.16f, 0.16f); + std::uniform_real_distribution shardVY(-0.22f, -0.06f); + std::uniform_real_distribution shardSize(finalBlockSize * 0.06f, finalBlockSize * 0.12f); + for (const auto& p : bursts) { + if (p.x < 0 || p.x >= Game::COLS || p.y < 0 || p.y >= Game::ROWS) { + continue; + } + float fx = gridX + (static_cast(p.x) + 0.5f) * finalBlockSize; + float fy = gridY + (static_cast(p.y) + 0.5f) * finalBlockSize + rowDropOffsets[p.y]; + + SDL_Color palette[3] = { + SDL_Color{255, 230, 120, 255}, + SDL_Color{140, 220, 255, 255}, + SDL_Color{255, 160, 235, 255} + }; + SDL_Color c = palette[s_impactRng() % 3]; + AsteroidBurst burst{ + fx, + fy, + lifeDist(s_impactRng), + 0.0f, + radiusDist(s_impactRng), + c, + spinDist(s_impactRng) + }; + burst.maxLifeMs = burst.lifeMs; + s_asteroidBursts.push_back(burst); + + // Spawn shards for extra sparkle + int shardCount = 10 + (s_impactRng() % 8); + for (int i = 0; i < shardCount; ++i) { + AsteroidShard shard{ + fx, + fy, + shardVX(s_impactRng), + shardVY(s_impactRng), + shardLife(s_impactRng), + 0.0f, + shardSize(s_impactRng), + c + }; + shard.maxLifeMs = shard.lifeMs; + s_asteroidShards.push_back(shard); + } + } + game->clearRecentAsteroidExplosions(); + } + } + // Draw the game board const auto &board = game->boardRef(); + const auto &asteroidCells = game->asteroidCells(); + const bool challengeMode = game->getMode() == GameMode::Challenge; float impactStrength = 0.0f; float impactEased = 0.0f; std::array impactMask{}; @@ -937,6 +1087,25 @@ void GameRenderer::renderPlayingState( } } + std::array challengeClearMask{}; + const bool challengeClearActive = challengeClearFxActive && challengeClearFxOrder && !challengeClearFxOrder->empty() && challengeClearFxDurationMs > 0.0; + if (challengeClearActive) { + const double totalDuration = std::max(50.0, challengeClearFxDurationMs); + const double perCell = totalDuration / static_cast(challengeClearFxOrder->size()); + for (size_t i = 0; i < challengeClearFxOrder->size(); ++i) { + int idx = (*challengeClearFxOrder)[i]; + if (idx < 0 || idx >= static_cast(challengeClearMask.size())) { + continue; + } + double startMs = perCell * static_cast(i); + double local = (challengeClearFxElapsedMs - startMs) / perCell; + float progress = static_cast(std::clamp(local, 0.0, 1.0)); + if (progress > 0.0f) { + challengeClearMask[idx] = progress; + } + } + } + for (int y = 0; y < Game::ROWS; ++y) { float dropOffset = rowDropOffsets[y]; for (int x = 0; x < Game::COLS; ++x) { @@ -954,11 +1123,134 @@ void GameRenderer::renderPlayingState( bx += amplitude * std::sin(t * freq); by += amplitude * 0.75f * std::cos(t * (freq + 1.1f)); } - drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1); + + float clearProgress = challengeClearMask[cellIdx]; + float clearAlpha = 1.0f; + float clearScale = 1.0f; + if (clearProgress > 0.0f) { + float eased = smoothstep(clearProgress); + clearAlpha = std::max(0.0f, 1.0f - eased); + clearScale = 1.0f + 0.35f * eased; + float offset = (finalBlockSize - finalBlockSize * clearScale) * 0.5f; + bx += offset; + by += offset; + float jitter = eased * 2.0f; + bx += std::sin(static_cast(cellIdx) * 3.1f) * jitter; + by += std::cos(static_cast(cellIdx) * 2.3f) * jitter * 0.6f; + } + + bool isAsteroid = challengeMode && asteroidCells[cellIdx].has_value(); + if (isAsteroid) { + const AsteroidCell& cell = *asteroidCells[cellIdx]; + float spawnScale = 1.0f; + float spawnAlpha = 1.0f; + if (countdownActive) { + // Staggered pop-in while counting: start oversized, fade to 1.0 with ease + const float t = static_cast(SDL_GetTicks() & 2047) * 0.0015f; // ~0..3s loop + float phase = std::fmod(t + (float(cellIdx % 11) * 0.12f), 1.6f); + float pulse = std::clamp(phase / 1.2f, 0.0f, 1.0f); + float eased = smoothstep(pulse); + spawnScale = 1.35f - 0.35f * eased; // big -> normal + spawnAlpha = 0.25f + 0.75f * eased; // fade in + } + + if (asteroidsTex && spawnAlpha < 1.0f) { + SDL_SetTextureAlphaMod(asteroidsTex, static_cast(std::clamp(spawnAlpha, 0.0f, 1.0f) * 255.0f)); + } + + float size = finalBlockSize * spawnScale * clearScale; + float offset = (finalBlockSize - size) * 0.5f; + if (asteroidsTex && clearAlpha < 1.0f) { + Uint8 alpha = static_cast(std::clamp(spawnAlpha * clearAlpha, 0.0f, 1.0f) * 255.0f); + SDL_SetTextureAlphaMod(asteroidsTex, alpha); + } + drawAsteroid(renderer, asteroidsTex, bx + offset, by + offset, size, cell); + + if (asteroidsTex && (spawnAlpha < 1.0f || clearAlpha < 1.0f)) { + SDL_SetTextureAlphaMod(asteroidsTex, 255); + } + } else { + if (blocksTex && clearAlpha < 1.0f) { + SDL_SetTextureAlphaMod(blocksTex, static_cast(std::clamp(clearAlpha, 0.0f, 1.0f) * 255.0f)); + } + drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize * clearScale, v - 1); + if (blocksTex && clearAlpha < 1.0f) { + SDL_SetTextureAlphaMod(blocksTex, 255); + } + } } } } + // Update & draw asteroid glamour shards and bursts + if (!s_asteroidShards.empty() || !s_asteroidBursts.empty()) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); + + // Shards + auto shardIt = s_asteroidShards.begin(); + while (shardIt != s_asteroidShards.end()) { + AsteroidShard& s = *shardIt; + s.lifeMs -= sparkDeltaMs; + if (s.lifeMs <= 0.0f) { + shardIt = s_asteroidShards.erase(shardIt); + continue; + } + s.vy += 0.0007f * sparkDeltaMs; + s.x += s.vx * sparkDeltaMs; + s.y += s.vy * sparkDeltaMs; + float lifeRatio = std::clamp(static_cast(s.lifeMs / s.maxLifeMs), 0.0f, 1.0f); + Uint8 alpha = static_cast(lifeRatio * 200.0f); + SDL_SetRenderDrawColor(renderer, s.color.r, s.color.g, s.color.b, alpha); + float size = s.size * (0.7f + (1.0f - lifeRatio) * 0.8f); + SDL_FRect shardRect{ + s.x - size * 0.5f, + s.y - size * 0.5f, + size, + size * 1.4f + }; + SDL_RenderFillRect(renderer, &shardRect); + ++shardIt; + } + + // Bursts + auto it = s_asteroidBursts.begin(); + while (it != s_asteroidBursts.end()) { + AsteroidBurst& b = *it; + b.lifeMs -= sparkDeltaMs; + if (b.lifeMs <= 0.0f) { + it = s_asteroidBursts.erase(it); + continue; + } + float t = 1.0f - static_cast(b.lifeMs / b.maxLifeMs); + float alpha = std::clamp(1.0f - t, 0.0f, 1.0f); + float radius = b.baseRadius * (1.0f + t * 1.6f); + float thickness = std::max(2.0f, radius * 0.25f); + float jitter = std::sin(t * 12.0f + b.spin) * 2.0f; + + SDL_Color c = b.color; + Uint8 a = static_cast(alpha * 220.0f); + SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a); + SDL_FRect outer{ + b.x - radius + jitter, + b.y - radius + jitter, + radius * 2.0f, + radius * 2.0f + }; + SDL_RenderRect(renderer, &outer); + + SDL_FRect inner{ + b.x - (radius - thickness), + b.y - (radius - thickness), + (radius - thickness) * 2.0f, + (radius - thickness) * 2.0f + }; + SDL_SetRenderDrawColor(renderer, 255, 255, 255, static_cast(a * 0.9f)); + SDL_RenderRect(renderer, &inner); + ++it; + } + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + } + if (!s_impactSparks.empty()) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); auto it = s_impactSparks.begin(); @@ -986,7 +1278,7 @@ void GameRenderer::renderPlayingState( } } - bool allowActivePieceRender = !GameRenderer::isTransportActive(); + bool allowActivePieceRender = !GameRenderer::isTransportActive() && !challengeClearActive; const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled(); float activePiecePixelOffsetX = 0.0f; @@ -1287,8 +1579,12 @@ void GameRenderer::renderPlayingState( char levelStr[16]; snprintf(levelStr, sizeof(levelStr), "%02d", game->level()); + char challengeLevelStr[16]; + snprintf(challengeLevelStr, sizeof(challengeLevelStr), "%02d/100", game->challengeLevel()); + char asteroidStr[32]; + snprintf(asteroidStr, sizeof(asteroidStr), "%d LEFT", game->asteroidsRemaining()); - // Next level progress + // Next level progress (endless only) int startLv = game->startLevelBase(); int firstThreshold = (startLv + 1) * 10; int linesDone = game->lines(); @@ -1343,12 +1639,22 @@ void GameRenderer::renderPlayingState( 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 (game->getMode() == GameMode::Challenge) { + statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor}); + statLines.push_back({challengeLevelStr, 165.0f, 0.9f, valueColor}); + statLines.push_back({"ASTEROIDS", 200.0f, 1.0f, labelColor}); + statLines.push_back({asteroidStr, 225.0f, 0.9f, nextColor}); + statLines.push_back({"TIME", 265.0f, 1.0f, labelColor}); + statLines.push_back({timeStr, 290.0f, 0.9f, valueColor}); + } else { + 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}; @@ -1400,6 +1706,51 @@ void GameRenderer::renderPlayingState( pixelFont->draw(renderer, statsTextX, baseY + line.offsetY, line.text, line.scale, line.color); } + // Challenge story / briefing line near level indicator + if (challengeStoryText && !challengeStoryText->empty() && challengeStoryAlpha > 0.0f && game->getMode() == GameMode::Challenge) { + float alpha = std::clamp(challengeStoryAlpha, 0.0f, 1.0f); + SDL_Color storyColor{160, 220, 255, static_cast(std::lround(210.0f * alpha))}; + SDL_Color shadowColor{0, 0, 0, static_cast(std::lround(120.0f * alpha))}; + + auto drawWrapped = [&](const std::string& text, float x, float y, float maxW, float scale, SDL_Color color) { + std::istringstream iss(text); + std::string word; + std::string line; + float cursorY = y; + int lastH = 0; + while (iss >> word) { + std::string candidate = line.empty() ? word : (line + " " + word); + int w = 0, h = 0; + pixelFont->measure(candidate, scale, w, h); + if (w > maxW && !line.empty()) { + pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor); + pixelFont->draw(renderer, x, cursorY, line, scale, color); + cursorY += h + 4.0f; + line = word; + lastH = h; + } else { + line = candidate; + lastH = h; + } + } + if (!line.empty()) { + pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor); + pixelFont->draw(renderer, x, cursorY, line, scale, color); + cursorY += lastH + 4.0f; + } + }; + + float storyX = statsTextX; + float storyY = baseY + 112.0f; + float maxW = 230.0f; + if (scorePanelMetricsValid && scorePanelWidth > 40.0f) { + storyX = scorePanelLeftX + 14.0f; + maxW = std::max(160.0f, scorePanelWidth - 28.0f); + } + + drawWrapped(*challengeStoryText, storyX, storyY, maxW, 0.7f, storyColor); + } + if (debugEnabled) { pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255}); } diff --git a/src/graphics/renderers/GameRenderer.h b/src/graphics/renderers/GameRenderer.h index c4c730e..f8d360c 100644 --- a/src/graphics/renderers/GameRenderer.h +++ b/src/graphics/renderers/GameRenderer.h @@ -1,5 +1,7 @@ #pragma once #include +#include +#include #include "../../gameplay/core/Game.h" // Forward declarations @@ -21,15 +23,23 @@ public: FontAtlas* pixelFont, LineEffect* lineEffect, SDL_Texture* blocksTex, + SDL_Texture* asteroidsTex, SDL_Texture* statisticsPanelTex, SDL_Texture* scorePanelTex, SDL_Texture* nextPanelTex, SDL_Texture* holdPanelTex, + bool countdownActive, float logicalW, float logicalH, float logicalScale, float winW, - float winH + float winH, + bool challengeClearFxActive = false, + const std::vector* challengeClearFxOrder = nullptr, + double challengeClearFxElapsedMs = 0.0, + double challengeClearFxDurationMs = 0.0, + const std::string* challengeStoryText = nullptr, + float challengeStoryAlpha = 0.0f ); // Render the pause overlay (full screen) diff --git a/src/resources/AssetPaths.h b/src/resources/AssetPaths.h index ba58799..d050ac7 100644 --- a/src/resources/AssetPaths.h +++ b/src/resources/AssetPaths.h @@ -7,7 +7,8 @@ namespace Assets { inline constexpr const char* LOGO = "assets/images/spacetris.png"; inline constexpr const char* MAIN_SCREEN = "assets/images/main_screen.png"; - inline constexpr const char* BLOCKS_SPRITE = "assets/images/blocks90px_003.png"; + inline constexpr const char* BLOCKS_SPRITE = "assets/images/blocks90px_003.png"; + inline constexpr const char* ASTEROID_SPRITE = "assets/images/asteroids_001.png"; inline constexpr const char* PANEL_SCORE = "assets/images/panel_score.png"; inline constexpr const char* PANEL_STATS = "assets/images/statistics_panel.png"; inline constexpr const char* NEXT_PANEL = "assets/images/next_panel.png"; diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index d873037..1c22150 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -442,7 +442,7 @@ void MenuState::handleEvent(const SDL_Event& e) { case SDL_SCANCODE_LEFT: case SDL_SCANCODE_UP: { - const int total = 6; + const int total = 7; selectedButton = (selectedButton + total - 1) % total; // brief bright flash on navigation buttonFlash = 1.0; @@ -451,7 +451,7 @@ void MenuState::handleEvent(const SDL_Event& e) { case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_DOWN: { - const int total = 6; + const int total = 7; selectedButton = (selectedButton + 1) % total; // brief bright flash on navigation buttonFlash = 1.0; @@ -465,9 +465,22 @@ void MenuState::handleEvent(const SDL_Event& e) { } switch (selectedButton) { case 0: + // Endless play + if (ctx.game) ctx.game->setMode(GameMode::Endless); triggerPlay(); break; case 1: + // Start challenge run at level 1 + if (ctx.game) { + ctx.game->setMode(GameMode::Challenge); + if (ctx.skipNextLevelUpJingle) { + *ctx.skipNextLevelUpJingle = true; + } + ctx.game->startChallengeRun(1); + } + triggerPlay(); + break; + case 2: // Toggle inline level selector HUD (show/hide) if (!levelPanelVisible && !levelPanelAnimating) { levelPanelAnimating = true; @@ -479,7 +492,7 @@ void MenuState::handleEvent(const SDL_Event& e) { levelDirection = -1; // hide } break; - case 2: + case 3: // Toggle the options panel with an animated slide-in/out. if (!optionsVisible && !optionsAnimating) { optionsAnimating = true; @@ -489,7 +502,7 @@ void MenuState::handleEvent(const SDL_Event& e) { optionsDirection = -1; // hide } break; - case 3: + case 4: // Toggle the inline HELP HUD (show/hide) if (!helpPanelVisible && !helpPanelAnimating) { helpPanelAnimating = true; @@ -500,7 +513,7 @@ void MenuState::handleEvent(const SDL_Event& e) { helpDirection = -1; // hide } break; - case 4: + case 5: // Toggle the inline ABOUT HUD (show/hide) if (!aboutPanelVisible && !aboutPanelAnimating) { aboutPanelAnimating = true; @@ -510,7 +523,7 @@ void MenuState::handleEvent(const SDL_Event& e) { aboutDirection = -1; } break; - case 5: + case 6: // Show the inline exit HUD if (!exitPanelVisible && !exitPanelAnimating) { exitPanelAnimating = true; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index f055ffb..21fee29 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -18,12 +18,18 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {} void PlayingState::onEnter() { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state"); - // Initialize the game with the selected starting level - if (ctx.game && ctx.startLevelSelection) { - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection); - ctx.game->reset(*ctx.startLevelSelection); - } + // Initialize the game based on mode: endless uses chosen start level, challenge keeps its run state if (ctx.game) { + if (ctx.game->getMode() == GameMode::Endless) { + if (ctx.startLevelSelection) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection); + ctx.game->reset(*ctx.startLevelSelection); + } + } else { + // Challenge run is prepared before entering; ensure gameplay is unpaused + ctx.game->setPaused(false); + } + s_lastPieceSequence = ctx.game->getCurrentPieceSequence(); s_pendingTransport = false; } @@ -116,6 +122,16 @@ void PlayingState::handleEvent(const SDL_Event& e) { return; } + // Debug: skip to next challenge level (B) + if (e.key.scancode == SDL_SCANCODE_B && ctx.game && ctx.game->getMode() == GameMode::Challenge) { + ctx.game->beginNextChallengeLevel(); + // Cancel any countdown so play resumes immediately on the new level + if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false; + if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false; + ctx.game->setPaused(false); + return; + } + // Tetris controls (only when not paused) if (!ctx.game->isPaused()) { // Hold / swap current piece (H) @@ -205,11 +221,15 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l bool exitPopup = ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup; bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) || (ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed); + bool challengeClearFx = ctx.challengeClearFxActive && *ctx.challengeClearFxActive; + const std::vector* challengeClearOrder = ctx.challengeClearFxOrder; + double challengeClearElapsed = ctx.challengeClearFxElapsedMs ? *ctx.challengeClearFxElapsedMs : 0.0; + double challengeClearDuration = ctx.challengeClearFxDurationMs ? *ctx.challengeClearFxDurationMs : 0.0; // Only blur if paused AND NOT in countdown (and not exit popup, though exit popup implies paused) // Actually, exit popup should probably still blur/dim. // But countdown should definitely NOT show the "PAUSED" overlay. - bool shouldBlur = paused && !countdown; + bool shouldBlur = paused && !countdown && !challengeClearFx; if (shouldBlur && m_renderTarget) { // Render game to texture @@ -235,15 +255,23 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.pixelFont, ctx.lineEffect, ctx.blocksTex, + ctx.asteroidsTex, ctx.statisticsPanelTex, ctx.scorePanelTex, ctx.nextPanelTex, ctx.holdPanelTex, + countdown, 1200.0f, // LOGICAL_W 1000.0f, // LOGICAL_H logicalScale, - (float)winW, - (float)winH + (float)winW, + (float)winH, + challengeClearFx, + challengeClearOrder, + challengeClearElapsed, + challengeClearDuration, + countdown ? nullptr : ctx.challengeStoryText, + countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f) ); // Reset to screen @@ -323,15 +351,23 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l ctx.pixelFont, ctx.lineEffect, ctx.blocksTex, + ctx.asteroidsTex, ctx.statisticsPanelTex, ctx.scorePanelTex, ctx.nextPanelTex, ctx.holdPanelTex, + countdown, 1200.0f, 1000.0f, logicalScale, (float)winW, - (float)winH + (float)winH, + challengeClearFx, + challengeClearOrder, + challengeClearElapsed, + challengeClearDuration, + countdown ? nullptr : ctx.challengeStoryText, + countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f) ); } } diff --git a/src/states/State.h b/src/states/State.h index 36a7a95..a9e0503 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -40,6 +40,7 @@ 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* asteroidsTex = nullptr; SDL_Texture* scorePanelTex = nullptr; SDL_Texture* statisticsPanelTex = nullptr; SDL_Texture* nextPanelTex = nullptr; @@ -66,6 +67,15 @@ struct StateContext { int* exitPopupSelectedButton = nullptr; // 0 = YES, 1 = NO (default) bool* gameplayCountdownActive = nullptr; // True if start-of-game countdown is running bool* menuPlayCountdownArmed = nullptr; // True if we are transitioning to play and countdown is pending + bool* skipNextLevelUpJingle = nullptr; // Allows states to silence initial level-up SFX + // Challenge clear FX (slow block-by-block explosion before next level) + bool* challengeClearFxActive = nullptr; + double* challengeClearFxElapsedMs = nullptr; + double* challengeClearFxDurationMs = nullptr; + std::vector* challengeClearFxOrder = nullptr; + std::string* challengeStoryText = nullptr; // Per-level briefing string for Challenge mode + int* challengeStoryLevel = nullptr; // Cached level for the current story line + float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade std::string* playerName = nullptr; // Shared player name buffer for highscores/options bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available std::function applyFullscreen; // Allows states to request fullscreen changes diff --git a/src/ui/BottomMenu.cpp b/src/ui/BottomMenu.cpp index 1910ca4..884c07d 100644 --- a/src/ui/BottomMenu.cpp +++ b/src/ui/BottomMenu.cpp @@ -22,11 +22,12 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) { std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false }; - menu.buttons[1] = Button{ BottomMenuItem::Level, rects[1], levelBtnText, true }; - menu.buttons[2] = Button{ BottomMenuItem::Options, rects[2], "OPTIONS", true }; - menu.buttons[3] = Button{ BottomMenuItem::Help, rects[3], "HELP", true }; - menu.buttons[4] = Button{ BottomMenuItem::About, rects[4], "ABOUT", true }; - menu.buttons[5] = Button{ BottomMenuItem::Exit, rects[5], "EXIT", true }; + menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false }; + menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true }; + menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true }; + menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true }; + menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true }; + menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", true }; return menu; } @@ -60,8 +61,15 @@ void renderBottomMenu(SDL_Renderer* renderer, const double aMul = std::clamp(baseMul + (playIsActive ? flashMul : 0.0), 0.0, 1.0); if (!b.textOnly) { + const bool isPlay = (i == 0); + const bool isChallenge = (i == 1); SDL_Color bgCol{ 18, 22, 28, static_cast(std::round(180.0 * aMul)) }; SDL_Color bdCol{ 255, 200, 70, static_cast(std::round(220.0 * aMul)) }; + if (isChallenge) { + // Give Challenge a teal accent to distinguish from Play + bgCol = SDL_Color{ 18, 36, 36, static_cast(std::round(190.0 * aMul)) }; + bdCol = SDL_Color{ 120, 255, 220, static_cast(std::round(230.0 * aMul)) }; + } UIRenderer::drawButton(renderer, font, cx, cy, r.w, r.h, b.label, isHovered, isSelected, bgCol, bdCol, false, nullptr); @@ -74,14 +82,14 @@ void renderBottomMenu(SDL_Renderer* renderer, } } - // '+' separators between the bottom HUD buttons (indices 1..last) + // '+' separators between the bottom HUD buttons (indices 2..last) { SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE; SDL_GetRenderDrawBlendMode(renderer, &prevBlend); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast(std::round(180.0 * baseMul))); - const int firstSmall = 1; + const int firstSmall = 2; const int lastSmall = MENU_BTN_COUNT - 1; float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f; for (int i = firstSmall; i < lastSmall; ++i) { diff --git a/src/ui/BottomMenu.h b/src/ui/BottomMenu.h index 3f54fbe..3d1d1ee 100644 --- a/src/ui/BottomMenu.h +++ b/src/ui/BottomMenu.h @@ -15,11 +15,12 @@ namespace ui { enum class BottomMenuItem : int { Play = 0, - Level = 1, - Options = 2, - Help = 3, - About = 4, - Exit = 5, + Challenge = 1, + Level = 2, + Options = 3, + Help = 4, + About = 5, + Exit = 6, }; struct Button { diff --git a/src/ui/MenuLayout.cpp b/src/ui/MenuLayout.cpp index 5cf6560..34e6148 100644 --- a/src/ui/MenuLayout.cpp +++ b/src/ui/MenuLayout.cpp @@ -12,28 +12,32 @@ std::array computeMenuButtonRects(const MenuLayoutPar float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale; // Cockpit HUD layout (matches main_screen art): - // - A big centered PLAY button - // - A second row of 5 smaller buttons: LEVEL / OPTIONS / HELP / ABOUT / EXIT + // - Top row: PLAY and CHALLENGE (big buttons) + // - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons) const float marginX = std::max(24.0f, LOGICAL_W * 0.03f); const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f); const float availableW = std::max(120.0f, LOGICAL_W - marginX * 2.0f); - float playW = std::min(230.0f, availableW * 0.27f); - float playH = 35.0f; - float smallW = std::min(220.0f, availableW * 0.23f); + float playW = std::min(220.0f, availableW * 0.25f); + float playH = 36.0f; + float bigGap = 28.0f; + float smallW = std::min(210.0f, availableW * 0.22f); float smallH = 34.0f; - float smallSpacing = 28.0f; + float smallSpacing = 26.0f; // Scale down for narrow windows so nothing goes offscreen. - const int smallCount = MENU_BTN_COUNT - 1; + const int smallCount = MENU_BTN_COUNT - 2; float smallTotal = smallW * static_cast(smallCount) + smallSpacing * static_cast(smallCount - 1); - if (smallTotal > availableW) { - float s = availableW / smallTotal; + float topRowTotal = playW * 2.0f + bigGap; + if (smallTotal > availableW || topRowTotal > availableW) { + float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f); smallW *= s; smallH *= s; smallSpacing *= s; + playW *= s; + playH = std::max(26.0f, playH * std::max(0.75f, s)); + bigGap *= s; playW = std::min(playW, availableW); - playH *= std::max(0.75f, s); } float centerX = LOGICAL_W * 0.5f + contentOffsetX; @@ -44,7 +48,11 @@ std::array computeMenuButtonRects(const MenuLayoutPar float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f; std::array rects{}; - rects[0] = SDL_FRect{ centerX - playW * 0.5f, playCY - playH * 0.5f, playW, playH }; + // Top row big buttons + float playLeft = centerX - (playW + bigGap * 0.5f); + float challengeLeft = centerX + bigGap * 0.5f; + rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH }; + rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH }; float rowW = smallW * static_cast(smallCount) + smallSpacing * static_cast(smallCount - 1); float left = centerX - rowW * 0.5f; @@ -55,7 +63,7 @@ std::array computeMenuButtonRects(const MenuLayoutPar for (int i = 0; i < smallCount; ++i) { float x = left + i * (smallW + smallSpacing); - rects[i + 1] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH }; + rects[i + 2] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH }; } return rects; } diff --git a/src/ui/UIConstants.h b/src/ui/UIConstants.h index eb7b5c4..def95a5 100644 --- a/src/ui/UIConstants.h +++ b/src/ui/UIConstants.h @@ -1,6 +1,6 @@ #pragma once -static constexpr int MENU_BTN_COUNT = 6; +static constexpr int MENU_BTN_COUNT = 7; static constexpr float MENU_SMALL_THRESHOLD = 700.0f; static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f; static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W