Added challenge mode

This commit is contained in:
2025-12-20 13:08:16 +01:00
parent fd29ae271e
commit 34447f0245
15 changed files with 535 additions and 1227 deletions

View File

@ -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 <SDL3/SDL.h>
#include <memory>
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<SDL_Texture, SDL_TextureDeleter>;
using SDL_SurfacePtr = std::unique_ptr<SDL_Surface, SDL_SurfaceDeleter>;
```
---
### 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

View File

@ -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 <SDL3/SDL.h>
#include <memory>
/**
* @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<SDL_Texture, TextureDeleter>;
/**
* @brief Smart pointer for SDL_Surface
*/
using SurfacePtr = std::unique_ptr<SDL_Surface, SurfaceDeleter>;
/**
* @brief Smart pointer for SDL_Renderer
*/
using RendererPtr = std::unique_ptr<SDL_Renderer, RendererDeleter>;
/**
* @brief Smart pointer for SDL_Window
*/
using WindowPtr = std::unique_ptr<SDL_Window, WindowDeleter>;
} // 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<SDL_Texture*, 4> icons = {
playIcon,
levelIcon,
optionsIcon,
exitIcon
};
```
**After:**
```cpp
std::array<SDL_Texture*, 4> 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 <SDL3/SDL.h>
/**
* @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<typename... Args>
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<typename... Args>
inline void debug(const char* fmt, Args... args) {
SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
}
/**
* @brief Log an info message
*/
template<typename... Args>
inline void info(const char* fmt, Args... args) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
}
/**
* @brief Log a warning message
*/
template<typename... Args>
inline void warn(const char* fmt, Args... args) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
}
/**
* @brief Log an error message
*/
template<typename... Args>
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!** 🚀

View File

@ -1,5 +0,0 @@
#include <SDL3/SDL.h>
#include <iostream>
int main() { std::cout << \
SDL_EVENT_QUIT:
\ << SDL_EVENT_QUIT << std::endl; return 0; }

View File

@ -6,7 +6,7 @@ Fullscreen=1
[Audio]
Music=1
Sound=1
Sound=0
[Gameplay]
SmoothScroll=1

View File

@ -703,7 +703,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) {
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 +737,14 @@ 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);
game->startChallengeRun(1);
}
startMenuPlayTransition();
break;
case ui::BottomMenuItem::Level:

View File

@ -51,7 +51,10 @@ 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();
std::fill(blockCounts.begin(), blockCounts.end(), 0);
bag.clear();
_score = 0; _lines = 0; _level = startLevel_; startLevel = startLevel_;
@ -59,6 +62,8 @@ void Game::reset(int startLevel_) {
_currentCombo = 0;
_maxCombo = 0;
_comboCount = 0;
challengeComplete = false;
challengeLevelActive = false;
// Initialize gravity using NES timing table (ms per cell by level)
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
fallAcc = 0; gameOver=false; paused=false;
@ -72,6 +77,206 @@ void Game::reset(int startLevel_) {
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<uint32_t>(SDL_GetTicks());
}
challengeRng.seed(challengeSeedBase + static_cast<uint32_t>(lvl));
setupChallengeLevel(lvl, false);
}
void Game::beginNextChallengeLevel() {
if (mode != GameMode::Challenge || challengeComplete) {
return;
}
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;
// Refresh deterministic RNG for this level
challengeRng.seed(challengeSeedBase + static_cast<uint32_t>(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;
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<double>(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<int> 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<int> xDist(0, COLS - 1);
std::uniform_int_distribution<int> 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] = ASTEROID_BASE + static_cast<int>(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);
}
}
double Game::elapsed() const {
@ -235,6 +440,7 @@ void Game::lockPiece() {
_tetrisesMade += 1;
}
if (mode != GameMode::Challenge) {
// JS level progression (NES-like) using starting level rules
// Both startLevel and _level are 0-based now.
int targetLevel = startLevel;
@ -253,6 +459,10 @@ void Game::lockPiece() {
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
if (soundCallback) {
@ -296,29 +506,129 @@ void Game::clearCompletedLines() {
void Game::actualClearLines() {
if (completedLines.empty()) return;
int write = ROWS - 1;
std::array<int, COLS*ROWS> newBoard{};
std::array<std::optional<AsteroidCell>, 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();
if (mode == GameMode::Challenge) {
if (asteroidsRemainingCount <= 0) {
beginNextChallengeLevel();
}
}
}
void Game::handleAsteroidsOnClearedRows(const std::vector<int>& clearedRows,
std::array<int, COLS*ROWS>& outBoard,
std::array<std::optional<AsteroidCell>, COLS*ROWS>& outAsteroids) {
std::vector<bool> clearedFlags(ROWS, false);
for (int r : clearedRows) {
if (r >= 0 && r < ROWS) {
clearedFlags[r] = true;
}
}
// Track asteroid count updates during processing
int destroyedThisPass = 0;
// Precompute how many cleared rows are at or below each row to reposition survivors
std::array<int, ROWS> 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;
}
}
--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 y = ROWS - 1; y >= 0; --y) {
for (int x = 0; x < COLS; ++x) {
board[y*COLS + x] = 0;
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++;
continue;
}
// Update visual/gravity state for surviving asteroids
cell.visualState = static_cast<uint8_t>(std::min<int>(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] = ASTEROID_BASE + static_cast<int>(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];
}
}
}
}
if (destroyedThisPass > 0) {
asteroidsRemainingCount = std::max(0, asteroidsRemainingCount - destroyedThisPass);
}
}
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() {

View File

@ -7,12 +7,26 @@
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <SDL3/SDL.h>
#include "../../core/GravityManager.h"
enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
using Shape = std::array<uint16_t, 4>; // 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,20 @@ 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; }
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<std::optional<AsteroidCell>, COLS*ROWS>& asteroidCells() const { return asteroidGrid; }
// Block statistics
const std::array<int, PIECE_COUNT>& getBlockCounts() const { return blockCounts; }
@ -87,6 +110,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<int, COLS*ROWS> board{}; // 0 empty else color index
Piece cur{}, hold{}, nextPiece{}; // current, held & next piece
bool canHold{true};
@ -132,6 +158,17 @@ 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<std::optional<AsteroidCell>, COLS*ROWS> asteroidGrid{};
uint32_t challengeSeedBase{0};
std::mt19937 challengeRng{ std::random_device{}() };
bool challengeLevelActive{false};
// Internal helpers ----------------------------------------------------
void refillBag();
void spawn();
@ -140,5 +177,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<int>& clearedRows,
std::array<int, COLS*ROWS>& outBoard,
std::array<std::optional<AsteroidCell>, COLS*ROWS>& outAsteroids);
void applyAsteroidGravity();
// Gravity tuning helpers (public API declared above)
};

View File

@ -867,6 +867,8 @@ void GameRenderer::renderPlayingState(
// 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<uint8_t, Game::COLS * Game::ROWS> impactMask{};
@ -954,10 +956,42 @@ void GameRenderer::renderPlayingState(
bx += amplitude * std::sin(t * freq);
by += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
}
bool isAsteroid = challengeMode && asteroidCells[cellIdx].has_value();
if (isAsteroid) {
const AsteroidCell& cell = *asteroidCells[cellIdx];
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<float>(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f);
SDL_Color fill{
static_cast<Uint8>(base.r * hpScale + 40 * (1.0f - hpScale)),
static_cast<Uint8>(base.g * hpScale + 40 * (1.0f - hpScale)),
static_cast<Uint8>(base.b * hpScale + 40 * (1.0f - hpScale)),
255
};
drawRect(renderer, bx, by, finalBlockSize - 1, finalBlockSize - 1, fill);
// Subtle outline to differentiate types
SDL_Color outline = base;
outline.a = 220;
SDL_FRect border{bx + 1.0f, by + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a);
SDL_RenderRect(renderer, &border);
if (cell.gravityEnabled) {
SDL_SetRenderDrawColor(renderer, 255, 230, 120, 180);
SDL_FRect glow{bx + 2.0f, by + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
SDL_RenderRect(renderer, &glow);
}
} else {
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1);
}
}
}
}
if (!s_impactSparks.empty()) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
@ -1287,8 +1321,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 +1381,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});
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};

View File

@ -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,19 @@ 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);
ctx.game->startChallengeRun(1);
}
triggerPlay();
break;
case 2:
// Toggle inline level selector HUD (show/hide)
if (!levelPanelVisible && !levelPanelAnimating) {
levelPanelAnimating = true;
@ -479,7 +489,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 +499,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 +510,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 +520,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;

View File

@ -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) {
// 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);
}
if (ctx.game) {
} else {
// Challenge run is prepared before entering; ensure gameplay is unpaused
ctx.game->setPaused(false);
}
s_lastPieceSequence = ctx.game->getCurrentPieceSequence();
s_pendingTransport = false;
}

View File

@ -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<Uint8>(std::round(180.0 * aMul)) };
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
if (isChallenge) {
// Give Challenge a teal accent to distinguish from Play
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(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<Uint8>(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) {

View File

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

View File

@ -12,28 +12,32 @@ std::array<SDL_FRect, MENU_BTN_COUNT> 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<float>(smallCount) + smallSpacing * static_cast<float>(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<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
rects[0] = SDL_FRect{ centerX - playW * 0.5f, playCY - playH * 0.5f, playW, playH };
// 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<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
float left = centerX - rowW * 0.5f;
@ -55,7 +63,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> 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;
}

View File

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