diff --git a/.copilot-rules.md b/.copilot-rules.md new file mode 100644 index 0000000..cafc1cc --- /dev/null +++ b/.copilot-rules.md @@ -0,0 +1,168 @@ +# Copilot Rules — Spacetris (SDL3 / C++20) + +These rules define **non-negotiable constraints** for all AI-assisted changes. +They exist to preserve determinism, performance, and architecture. + +If these rules conflict with `.github/copilot-instructions.md`, +**follow `.github/copilot-instructions.md`.** + +--- + +## Project Constraints (Non-Negotiable) + +- Language: **C++20** +- Runtime: **SDL3** + **SDL3_ttf** +- Build system: **CMake** +- Dependencies via **vcpkg** +- Assets must use **relative paths only** +- Deterministic gameplay logic is mandatory + +Do not rewrite or refactor working systems unless explicitly requested. + +--- + +## Repo Layout & Responsibilities + +- Core gameplay loop/state: `src/Game.*` +- Entry point: `src/main.cpp` +- Text/TTF: `src/Font.*` +- Audio: `src/Audio.*`, `src/SoundEffect.*` +- Effects: `src/LineEffect.*`, `src/Starfield*.cpp` +- High scores: `src/Scores.*` +- Packaging: `build-production.ps1` + +When adding a module: +- Place it under `src/` (or an established subfolder) +- Register it in `CMakeLists.txt` +- Avoid circular includes +- Keep headers minimal + +--- + +## Build & Verification + +Prefer existing scripts: + +- Debug: `cmake --build build-msvc --config Debug` +- Release: + - Configure: `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release` + - Build: `cmake --build build-release --config Release` +- Packaging (Windows): `./build-production.ps1` + +Before finalizing changes: +- Debug build must succeed +- Packaging must succeed if assets or DLLs are touched + +Do not introduce new build steps unless required. + +--- + +## Coding & Architecture Rules + +- Match local file style (naming, braces, spacing) +- Avoid large refactors +- Prefer small, testable helpers +- Avoid floating-point math in core gameplay state +- Game logic must be deterministic +- Rendering code must not mutate game state + +--- + +## Rendering & Performance Rules + +- Do not allocate memory per frame +- Do not load assets during rendering +- No blocking calls in render loop +- Visual effects must be time-based (`deltaTime`) +- Rendering must not contain gameplay logic + +--- + +## Threading Rules + +- SDL main thread: + - Rendering + - Input + - Game simulation +- Networking must be **non-blocking** from the SDL main loop + - Either run networking on a separate thread, or poll ENet frequently with a 0 timeout + - Never wait/spin for remote inputs on the render thread +- Cross-thread communication via queues or buffers only + +--- + +## Assets, Fonts, and Paths + +- Runtime expects adjacent `assets/` directory +- `FreeSans.ttf` must remain at repo root +- New assets: + - Go under `assets/` + - Must be included in `build-production.ps1` + +Never hardcode machine-specific paths. + +--- + +## AI Partner (COOPERATE Mode) + +- AI is **supportive**, not competitive +- AI must respect sync timing and shared grid logic +- AI must not “cheat” or see hidden future pieces +- AI behavior must be deterministic per seed/difficulty + +--- + +## Networking (COOPERATE Network Mode) + +Follow `docs/ai/cooperate_network.md`. +If `network_cooperate_multiplayer.md` exists, keep it consistent with the canonical doc. + +Mandatory model: +- **Input lockstep** +- Transmit inputs only (no board state replication) + +Determinism requirements: +- Fixed tick (e.g. 60 Hz) +- Shared RNG seed +- Deterministic gravity, rotation, locking, scoring + +Technology: +- Use **ENet** +- Do NOT use SDL_net or TCP-only networking + +Architecture: +- Networking must be isolated (e.g. `src/network/NetSession.*`) +- Game logic must not care if partner is local, AI, or network + +Robustness: +- Input delay buffer (4–6 ticks) +- Periodic desync hashing +- Graceful disconnect handling + +Do NOT implement: +- Rollback +- Full state sync +- Server-authoritative sim +- Matchmaking SDKs +- Versus mechanics + +--- + +## Agent Behavior Rules (IMPORTANT) + +- Always read relevant markdown specs **before coding** +- Treat markdown specs as authoritative +- Do not invent APIs +- Do not assume external libraries exist +- Generate code **file by file**, not everything at once +- Ask before changing architecture or ownership boundaries + +--- + +## When to Ask Before Proceeding + +Ask the maintainer if unclear: +- UX or menu flow decisions +- Adding dependencies +- Refactors vs local patches +- Platform-specific behavior diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 351ae6b..8c9515b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -# Copilot Instructions — Tetris (C++ SDL3) +# Copilot Instructions — Spacetris (C++ SDL3) Purpose: Speed up development on this native SDL3 Tetris. Follow these conventions to keep builds reproducible and packages shippable. @@ -9,11 +9,9 @@ Purpose: Speed up development on this native SDL3 Tetris. Follow these conventio - Assets: `assets/` (images/music/fonts), plus `FreeSans.ttf` at repo root. ## Build and run -- Configure and build Release: - CMake picks up vcpkg toolchain if found (`VCPKG_ROOT`, local `vcpkg/`, or user path). Required packages: `sdl3`, `sdl3-ttf` (see `vcpkg.json`). - Typical sequence (PowerShell): `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release` then `cmake --build build-release --config Release`. -- Packaging: `.\build-production.ps1` creates `dist/TetrisGame/` with exe, DLLs, assets, README, and ZIP; it can clean via `-Clean` and package-only via `-PackageOnly`. -- MSVC generator builds are under `build-msvc/` when using Visual Studio. +Packaging: `.\build-production.ps1` creates `dist/SpacetrisGame/` with exe, DLLs, assets, README, and ZIP; it can clean via `-Clean` and package-only via `-PackageOnly`. ## Runtime dependencies - Links: `SDL3::SDL3`, `SDL3_ttf::SDL3_ttf`; on Windows also `mfplat`, `mfreadwrite`, `mfuuid` for media. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75a0a73..6e8095d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build and Package Tetris +name: Build and Package Spacetris on: push: @@ -43,20 +43,20 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: tetris-windows-x64 - path: dist/TetrisGame/ + name: spacetris-windows-x64 + path: dist/SpacetrisGame/ - name: Create Release ZIP if: startsWith(github.ref, 'refs/tags/v') run: | cd dist - 7z a ../TetrisGame-Windows-x64.zip TetrisGame/ + 7z a ../SpacetrisGame-Windows-x64.zip SpacetrisGame/ - name: Release if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v1 with: - files: TetrisGame-Windows-x64.zip + files: SpacetrisGame-Windows-x64.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -83,32 +83,32 @@ jobs: - name: Package run: | - mkdir -p dist/TetrisGame-Linux - cp build/tetris dist/TetrisGame-Linux/ - cp -r assets dist/TetrisGame-Linux/ - cp FreeSans.ttf dist/TetrisGame-Linux/ - echo '#!/bin/bash' > dist/TetrisGame-Linux/launch-tetris.sh - echo 'cd "$(dirname "$0")"' >> dist/TetrisGame-Linux/launch-tetris.sh - echo './tetris' >> dist/TetrisGame-Linux/launch-tetris.sh - chmod +x dist/TetrisGame-Linux/launch-tetris.sh - chmod +x dist/TetrisGame-Linux/tetris + mkdir -p dist/SpacetrisGame-Linux + cp build/spacetris dist/SpacetrisGame-Linux/ + cp -r assets dist/SpacetrisGame-Linux/ + cp FreeSans.ttf dist/SpacetrisGame-Linux/ + echo '#!/bin/bash' > dist/SpacetrisGame-Linux/launch-spacetris.sh + echo 'cd "$(dirname "$0")"' >> dist/SpacetrisGame-Linux/launch-spacetris.sh + echo './spacetris' >> dist/SpacetrisGame-Linux/launch-spacetris.sh + chmod +x dist/SpacetrisGame-Linux/launch-spacetris.sh + chmod +x dist/SpacetrisGame-Linux/spacetris - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: tetris-linux-x64 - path: dist/TetrisGame-Linux/ + name: spacetris-linux-x64 + path: dist/SpacetrisGame-Linux/ - name: Create Release TAR if: startsWith(github.ref, 'refs/tags/v') run: | cd dist - tar -czf ../TetrisGame-Linux-x64.tar.gz TetrisGame-Linux/ + tar -czf ../SpacetrisGame-Linux-x64.tar.gz SpacetrisGame-Linux/ - name: Release if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v1 with: - files: TetrisGame-Linux-x64.tar.gz + files: SpacetrisGame-Linux-x64.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index b32beb6..8bba2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# .gitignore for Tetris (native C++ project and web subproject) +# .gitignore for Spacetris (native C++ project and web subproject) # Visual Studio / VS artifacts .vs/ @@ -70,4 +70,7 @@ dist_package/ # Local environment files (if any) .env +# Ignore local settings file +settings.ini + # End of .gitignore diff --git a/CMakeLists.txt b/CMakeLists.txt index 22a1a01..e64436a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,13 @@ elseif(EXISTS "C:/Users/Gregor Klevže/vcpkg/scripts/buildsystems/vcpkg.cmake") set(CMAKE_TOOLCHAIN_FILE "C:/Users/Gregor Klevže/vcpkg/scripts/buildsystems/vcpkg.cmake" CACHE STRING "") endif() -project(tetris_sdl3 LANGUAGES CXX) +# The toolchain file must be set before calling project() +if(DEFINED CMAKE_TOOLCHAIN_FILE) + message(STATUS "Using vcpkg toolchain: ${CMAKE_TOOLCHAIN_FILE}") + set(CMAKE_TOOLCHAIN_FILE "${CMAKE_TOOLCHAIN_FILE}" CACHE STRING "" FORCE) +endif() + +project(spacetris_sdl3 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -19,29 +25,94 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # Homebrew: brew install sdl3 find_package(SDL3 CONFIG REQUIRED) find_package(SDL3_ttf CONFIG REQUIRED) +find_package(SDL3_image CONFIG REQUIRED) +find_package(cpr CONFIG REQUIRED) +find_package(nlohmann_json CONFIG REQUIRED) +find_package(unofficial-enet CONFIG REQUIRED) -add_executable(tetris +set(TETRIS_SOURCES src/main.cpp - src/gameplay/Game.cpp + src/app/TetrisApp.cpp + src/gameplay/core/Game.cpp + src/gameplay/coop/CoopGame.cpp + src/gameplay/coop/CoopAIController.cpp src/core/GravityManager.cpp - src/core/StateManager.cpp + src/core/state/StateManager.cpp + # New core architecture classes + src/core/application/ApplicationManager.cpp + src/core/input/InputManager.cpp + src/core/assets/AssetManager.cpp + src/core/GlobalState.cpp + src/core/Settings.cpp + src/graphics/renderers/RenderManager.cpp src/persistence/Scores.cpp - src/graphics/Starfield.cpp - src/graphics/Starfield3D.cpp - src/graphics/Font.cpp + src/network/supabase_client.cpp + src/network/NetSession.cpp + src/graphics/effects/Starfield.cpp + src/graphics/effects/Starfield3D.cpp + src/graphics/effects/SpaceWarp.cpp + src/graphics/ui/Font.cpp + src/graphics/ui/HelpOverlay.cpp + src/graphics/renderers/GameRenderer.cpp + src/graphics/renderers/SyncLineRenderer.cpp + src/graphics/renderers/UIRenderer.cpp src/audio/Audio.cpp - src/gameplay/LineEffect.cpp + src/gameplay/effects/LineEffect.cpp src/audio/SoundEffect.cpp + src/video/VideoPlayer.cpp + src/ui/MenuLayout.cpp + src/ui/BottomMenu.cpp + src/app/BackgroundManager.cpp + src/app/Fireworks.cpp + src/app/AssetLoader.cpp + src/app/TextureLoader.cpp + src/states/LoadingManager.cpp # State implementations (new) src/states/LoadingState.cpp + src/states/VideoState.cpp src/states/MenuState.cpp + src/states/OptionsState.cpp + src/states/LevelSelectorState.cpp src/states/PlayingState.cpp ) + +if(APPLE) + set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns") + if(EXISTS "${APP_ICON}") + add_executable(spacetris MACOSX_BUNDLE ${TETRIS_SOURCES} "${APP_ICON}") + set_source_files_properties("${APP_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") + set_target_properties(spacetris PROPERTIES + MACOSX_BUNDLE_ICON_FILE "AppIcon" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in" + ) + else() + message(WARNING "App icon not found at ${APP_ICON}; bundle will use default icon") + add_executable(spacetris MACOSX_BUNDLE ${TETRIS_SOURCES}) + set_target_properties(spacetris PROPERTIES + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in" + ) + endif() +else() + add_executable(spacetris ${TETRIS_SOURCES}) +endif() + +# Ensure the built executable is named `spacetris`. +set_target_properties(spacetris PROPERTIES OUTPUT_NAME "spacetris") + +if (WIN32) + # No compatibility copy; built executable is `spacetris`. +endif() + if (WIN32) # Embed the application icon into the executable set_source_files_properties(src/app_icon.rc PROPERTIES LANGUAGE RC) - target_sources(tetris PRIVATE src/app_icon.rc) + target_sources(spacetris PRIVATE src/app_icon.rc) +endif() + +if(MSVC) + # Prevent PDB write contention on MSVC by enabling /FS for this target + target_compile_options(spacetris PRIVATE /FS) endif() if (WIN32) @@ -56,22 +127,58 @@ if (WIN32) COMMENT "Copy favicon.ico to build dir for resource compilation" ) add_custom_target(copy_favicon ALL DEPENDS ${FAVICON_DEST}) - add_dependencies(tetris copy_favicon) + add_dependencies(spacetris copy_favicon) else() message(WARNING "Favicon not found at ${FAVICON_SRC}; app icon may not compile") endif() # Also copy favicon into the runtime output folder (same dir as exe) - add_custom_command(TARGET tetris POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FAVICON_SRC} $/favicon.ico + add_custom_command(TARGET spacetris POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FAVICON_SRC} $/favicon.ico COMMENT "Copy favicon.ico next to executable" ) endif() -target_link_libraries(tetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf) +if(APPLE) + set(_mac_copy_commands) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets") + list(APPEND _mac_copy_commands + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "$/assets" + ) + endif() + if(EXISTS "${CMAKE_SOURCE_DIR}/fonts") + list(APPEND _mac_copy_commands + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/fonts" "$/fonts" + ) + endif() + if(EXISTS "${CMAKE_SOURCE_DIR}/FreeSans.ttf") + list(APPEND _mac_copy_commands + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "$/FreeSans.ttf" + ) + endif() + if(_mac_copy_commands) + add_custom_command(TARGET spacetris POST_BUILD + ${_mac_copy_commands} + COMMENT "Copying game assets into macOS bundle" + ) + endif() +endif() + +target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet) + +find_package(FFMPEG REQUIRED) +if(FFMPEG_FOUND) + target_include_directories(spacetris PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_directories(spacetris PRIVATE ${FFMPEG_LIBRARY_DIRS}) + target_link_libraries(spacetris PRIVATE ${FFMPEG_LIBRARIES}) +endif() if (WIN32) - target_link_libraries(tetris PRIVATE mfplat mfreadwrite mfuuid) + target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm) +endif() +if(APPLE) + # Needed for MP3 decoding via AudioToolbox on macOS + target_link_libraries(spacetris PRIVATE "-framework AudioToolbox" "-framework CoreFoundation") endif() # Include production build configuration @@ -82,23 +189,26 @@ enable_testing() # Unit tests (simple runner) find_package(Catch2 CONFIG REQUIRED) -add_executable(tetris_tests +add_executable(spacetris_tests tests/GravityTests.cpp src/core/GravityManager.cpp ) -target_include_directories(tetris_tests PRIVATE ${CMAKE_SOURCE_DIR}/src) -target_link_libraries(tetris_tests PRIVATE Catch2::Catch2WithMain) -add_test(NAME GravityTests COMMAND tetris_tests) +target_include_directories(spacetris_tests PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(spacetris_tests PRIVATE Catch2::Catch2WithMain) +add_test(NAME GravityTests COMMAND spacetris_tests) if(EXISTS "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include") - target_include_directories(tetris_tests PRIVATE "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include") + target_include_directories(spacetris_tests PRIVATE "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include") endif() # Add new src subfolders to include path so old #includes continue to work -target_include_directories(tetris PRIVATE +target_include_directories(spacetris PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src/audio + ${CMAKE_SOURCE_DIR}/src/video ${CMAKE_SOURCE_DIR}/src/gameplay ${CMAKE_SOURCE_DIR}/src/graphics ${CMAKE_SOURCE_DIR}/src/persistence + ${CMAKE_SOURCE_DIR}/src/core + ${CMAKE_SOURCE_DIR}/src/states ) \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4913e93..f065e35 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,4 +1,4 @@ -# Tetris SDL3 - Production Deployment Guide +# Spacetris SDL3 - Production Deployment Guide ## 🚀 Build Scripts Available @@ -9,7 +9,7 @@ - Uses existing Debug/Release build from build-msvc - Creates distribution package with all dependencies - **Size: ~939 MB** (includes all assets and music) -- **Output:** `dist/TetrisGame/` + ZIP file +- **Output:** `dist/SpacetrisGame/` + ZIP file ### 2. Full Production Build ```powershell @@ -31,8 +31,8 @@ build-production.bat The distribution package includes: ### Essential Files -- ✅ `tetris.exe` - Main game executable -- ✅ `Launch-Tetris.bat` - Safe launcher with error handling +- ✅ `spacetris.exe` - Main game executable +- ✅ `Launch-Spacetris.bat` - Safe launcher with error handling - ✅ `README.txt` - User instructions ### Dependencies @@ -49,9 +49,9 @@ The distribution package includes: ## 🎯 Distribution Options ### Option 1: ZIP Archive (Recommended) -- **File:** `TetrisGame-YYYY.MM.DD.zip` +- **File:** `SpacetrisGame-YYYY.MM.DD.zip` - **Size:** ~939 MB -- **Usage:** Users extract and run `Launch-Tetris.bat` +- **Usage:** Users extract and run `Launch-Spacetris.bat` ### Option 2: Installer (Future) - Use CMake CPack to create NSIS installer @@ -60,14 +60,14 @@ The distribution package includes: ``` ### Option 3: Portable Folder -- Direct distribution of `dist/TetrisGame/` folder + Direct distribution of `dist/SpacetrisGame/` folder - Users copy folder and run executable - + [ ] Launch-Spacetris.bat works ## 🔧 Build Requirements - + **Solution:** Use `Launch-Spacetris.bat` for better error reporting ### Development Environment - **CMake** 3.20+ -- **Visual Studio 2022** (or compatible C++ compiler) +- **Visual Studio 2026 (VS 18)** with Desktop development with C++ workload - **vcpkg** package manager - **SDL3** libraries (installed via vcpkg) @@ -87,18 +87,21 @@ vcpkg install sdl3 sdl3-ttf --triplet=x64-windows - [ ] Font rendering works (both FreeSans and PressStart2P) - [ ] Game saves high scores +### Package Validation ### Package Validation - [ ] All DLL files present - [ ] Assets folder complete -- [ ] Launch-Tetris.bat works +- [ ] Launch-Spacetris.bat works - [ ] README.txt is informative - [ ] Package size reasonable (~939 MB) ### Distribution -- [ ] ZIP file created successfully +### "spacetris.exe is not recognized" +- **Solution:** Ensure all DLL files are in same folder as executable - [ ] Test extraction on clean system - [ ] Verify game runs on target machines -- [ ] No missing dependencies +### Game won't start +- **Solution:** Use `Launch-Spacetris.bat` for better error reporting ## 📋 User System Requirements @@ -118,7 +121,7 @@ vcpkg install sdl3 sdl3-ttf --triplet=x64-windows ## 🐛 Common Issues & Solutions -### "tetris.exe is not recognized" +### "spacetris.exe is not recognized" - **Solution:** Ensure all DLL files are in same folder as executable ### "Failed to initialize SDL" diff --git a/FreeSans.ttf b/FreeSans.ttf deleted file mode 100644 index 9db9585..0000000 Binary files a/FreeSans.ttf and /dev/null differ diff --git a/I_PIECE_SPAWN_FIX.md b/I_PIECE_SPAWN_FIX.md deleted file mode 100644 index 285af34..0000000 --- a/I_PIECE_SPAWN_FIX.md +++ /dev/null @@ -1,160 +0,0 @@ -# I-Piece Spawn Position Fix - -## Overview -Fixed the I-piece (straight line tetromino) to spawn one row higher than other pieces, addressing the issue where the I-piece only occupied one line when spawning vertically, making its entry appear less natural. - -## Problem Analysis - -### Issue Identified -The I-piece has unique spawn behavior because: -- **Vertical I-piece**: When spawning in vertical orientation (rotation 0), it only occupies one row -- **Visual Impact**: This made the I-piece appear to "pop in" at the top grid line rather than naturally entering -- **Player Experience**: Less time to react and plan placement compared to other pieces - -### Other Pieces -All other pieces (O, T, S, Z, J, L) were working perfectly at their current spawn position (`y = -1`) and should remain unchanged. - -## Solution Implemented - -### Targeted I-Piece Fix -Instead of changing all pieces, implemented piece-type-specific spawn logic: - -```cpp -// I-piece spawns higher due to its unique height characteristics -int spawnY = (pieceType == I) ? -2 : -1; -``` - -### Code Changes - -#### 1. Updated spawn() function: -```cpp -void Game::spawn() { - if (bag.empty()) refillBag(); - PieceType pieceType = bag.back(); - - // I-piece needs to start one row higher due to its height when vertical - int spawnY = (pieceType == I) ? -2 : -1; - - cur = Piece{ pieceType, 0, 3, spawnY }; - bag.pop_back(); - blockCounts[cur.type]++; - canHold = true; - - // Prepare next piece with same logic - if (bag.empty()) refillBag(); - PieceType nextType = bag.back(); - int nextSpawnY = (nextType == I) ? -2 : -1; - nextPiece = Piece{ nextType, 0, 3, nextSpawnY }; -} -``` - -#### 2. Updated holdCurrent() function: -```cpp -// Apply I-piece specific positioning in hold mechanics -int holdSpawnY = (hold.type == I) ? -2 : -1; -int currentSpawnY = (temp.type == I) ? -2 : -1; -``` - -## Technical Benefits - -### 1. Piece-Specific Optimization -- **I-Piece**: Spawns at `y = -2` for natural vertical entry -- **Other Pieces**: Remain at `y = -1` for optimal gameplay feel -- **Consistency**: Each piece type gets appropriate spawn positioning - -### 2. Enhanced Gameplay -- **I-Piece Visibility**: More natural entry animation and player reaction time -- **Preserved Balance**: Other pieces maintain their optimized spawn positions -- **Strategic Depth**: I-piece placement becomes more strategic with better entry timing - -### 3. Code Quality -- **Targeted Solution**: Minimal code changes addressing specific issue -- **Maintainable Logic**: Clear piece-type conditionals -- **Extensible Design**: Easy to adjust other pieces if needed in future - -## Spawn Position Matrix - -``` -Piece Type | Spawn Y | Reasoning ------------|---------|------------------------------------------ -I-piece | -2 | Vertical orientation needs extra height -O-piece | -1 | Perfect 2x2 square positioning -T-piece | -1 | Optimal T-shape entry timing -S-piece | -1 | Natural S-shape appearance -Z-piece | -1 | Natural Z-shape appearance -J-piece | -1 | Optimal L-shape entry -L-piece | -1 | Optimal L-shape entry -``` - -## Visual Comparison - -### I-Piece Spawn Behavior: -``` -BEFORE (y = -1): AFTER (y = -2): -[ ] [ ] -[ █ ] ← I-piece here [ ] -[████████] [ █ ] ← I-piece here -[████████] [████████] - -Problem: Abrupt entry Solution: Natural entry -``` - -### Other Pieces (Unchanged): -``` -T-Piece Example (y = -1): -[ ] -[ ███ ] ← T-piece entry (perfect) -[████████] -[████████] - -Status: No change needed ✅ -``` - -## Testing Results - -### 1. I-Piece Verification -✅ **Vertical Spawn**: I-piece now appears naturally from above -✅ **Entry Animation**: Smooth transition into visible grid area -✅ **Player Reaction**: More time to plan I-piece placement -✅ **Hold Function**: I-piece maintains correct positioning when held/swapped - -### 2. Other Pieces Validation -✅ **O-Piece**: Maintains perfect 2x2 positioning -✅ **T-Piece**: Optimal T-shape entry preserved -✅ **S/Z-Pieces**: Natural zigzag entry unchanged -✅ **J/L-Pieces**: L-shape entry timing maintained - -### 3. Game Mechanics -✅ **Spawn Consistency**: Each piece type uses appropriate spawn height -✅ **Hold System**: Piece-specific positioning applied correctly -✅ **Bag Randomizer**: Next piece preview uses correct spawn height -✅ **Game Flow**: Smooth progression for all piece types - -## Benefits Summary - -### 1. Improved I-Piece Experience -- **Natural Entry**: I-piece now enters the play area smoothly -- **Better Timing**: More reaction time for strategic placement -- **Visual Polish**: Professional appearance matching commercial Tetris games - -### 2. Preserved Gameplay Balance -- **Other Pieces Unchanged**: Maintain optimal spawn positions for 6 other piece types -- **Consistent Feel**: Each piece type gets appropriate treatment -- **Strategic Depth**: I-piece becomes more strategic without affecting other pieces - -### 3. Technical Excellence -- **Minimal Changes**: Targeted fix without broad system changes -- **Future-Proof**: Easy to adjust individual piece spawn behavior -- **Code Quality**: Clear, maintainable piece-type logic - -## Status: ✅ COMPLETED - -- **I-Piece**: Now spawns at y = -2 for natural vertical entry -- **Other Pieces**: Remain at y = -1 for optimal gameplay -- **Hold System**: Updated to handle piece-specific spawn positions -- **Next Piece**: Preview system uses correct spawn heights -- **Testing**: Validated all piece types work correctly - -## Conclusion - -The I-piece now provides a much more natural gameplay experience with proper entry timing, while all other pieces maintain their optimal spawn positions. This targeted fix addresses the specific issue without disrupting the carefully balanced gameplay of other tetrominos. diff --git a/LAYOUT_IMPROVEMENTS.md b/LAYOUT_IMPROVEMENTS.md deleted file mode 100644 index 1bff1cb..0000000 --- a/LAYOUT_IMPROVEMENTS.md +++ /dev/null @@ -1,145 +0,0 @@ -# Gameplay Layout Improvements - Enhancement Report - -## Overview -Enhanced the gameplay state visual layout by repositioning the next piece preview higher above the main grid and adding subtle grid lines to improve gameplay visibility. - -## Changes Made - -### 1. Next Piece Preview Repositioning -**Problem**: The next piece preview was positioned too close to the main game grid, causing visual overlap and crowded appearance. - -**Solution**: -```cpp -// BEFORE: Limited space for next piece -const float NEXT_PIECE_HEIGHT = 80.0f; // Space reserved for next piece preview - -// AFTER: More breathing room -const float NEXT_PIECE_HEIGHT = 120.0f; // Space reserved for next piece preview (increased) -``` - -**Result**: -- ✅ **50% more space** above the main grid (80px → 120px) -- ✅ **Clear separation** between next piece and main game area -- ✅ **Better visual hierarchy** in the gameplay layout - -### 2. Main Grid Visual Enhancement -**Problem**: The main game grid lacked visual cell boundaries, making it difficult to precisely judge piece placement. - -**Solution**: Added subtle grid lines to show cell boundaries -```cpp -// Draw grid lines (subtle lines to show cell boundaries) -SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); // Slightly lighter than background - -// Vertical grid lines -for (int x = 1; x < Game::COLS; ++x) { - float lineX = gridX + x * finalBlockSize + contentOffsetX; - SDL_RenderLine(renderer, lineX, gridY + contentOffsetY, lineX, gridY + GRID_H + contentOffsetY); -} - -// Horizontal grid lines -for (int y = 1; y < Game::ROWS; ++y) { - float lineY = gridY + y * finalBlockSize + contentOffsetY; - SDL_RenderLine(renderer, gridX + contentOffsetX, lineY, gridX + GRID_W + contentOffsetX, lineY); -} -``` - -**Grid Line Properties**: -- **Color**: `RGB(40, 45, 60)` - Barely visible, subtle enhancement -- **Coverage**: Complete 10×20 grid with proper cell boundaries -- **Performance**: Minimal overhead with simple line drawing -- **Visual Impact**: Clear cell separation without visual noise - -## Technical Details - -### Layout Calculation System -The responsive layout system maintains perfect centering while accommodating the increased next piece space: - -```cpp -// Layout Components: -- Top Margin: 60px (UI spacing) -- Next Piece Area: 120px (increased from 80px) -- Main Grid: Dynamic based on window size -- Bottom Margin: 60px (controls text) -- Side Panels: 180px each (stats and score) -``` - -### Grid Line Implementation -- **Rendering Order**: Background → Grid Lines → Game Blocks → UI Elements -- **Line Style**: Single pixel lines with subtle contrast -- **Integration**: Seamlessly integrated with existing rendering pipeline -- **Responsiveness**: Automatically scales with dynamic block size - -## Visual Benefits - -### 1. Improved Gameplay Precision -- **Grid Boundaries**: Clear visual cell separation for accurate piece placement -- **Alignment Reference**: Easy to judge piece positioning and rotation -- **Professional Appearance**: Matches modern Tetris game standards - -### 2. Enhanced Layout Flow -- **Next Piece Visibility**: Higher positioning prevents overlap with main game -- **Visual Balance**: Better proportions between UI elements -- **Breathing Room**: More comfortable spacing throughout the interface - -### 3. Accessibility Improvements -- **Visual Clarity**: Subtle grid helps players with visual impairments -- **Reduced Eye Strain**: Better element separation reduces visual confusion -- **Gameplay Flow**: Smoother visual transitions between game areas - -## Comparison: Before vs After - -### Next Piece Positioning: -``` -BEFORE: [Next Piece] - [Main Grid ] ← Too close, visual overlap - -AFTER: [Next Piece] - - [Main Grid ] ← Proper separation, clear hierarchy -``` - -### Grid Visibility: -``` -BEFORE: Solid background, no cell boundaries - ■■■■■■■■■■ - ■■■■■■■■■■ ← Difficult to judge placement - -AFTER: Subtle grid lines show cell boundaries - ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ - ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ ← Easy placement reference - └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ -``` - -## Testing Results -✅ **Build Success**: Clean compilation with no errors -✅ **Visual Layout**: Next piece properly positioned above grid -✅ **Grid Lines**: Subtle, barely visible lines enhance gameplay -✅ **Responsive Design**: All improvements work across window sizes -✅ **Performance**: No measurable impact on frame rate -✅ **Game Logic**: All existing functionality preserved - -## User Experience Impact - -### 1. Gameplay Improvements -- **Precision**: Easier to place pieces exactly where intended -- **Speed**: Faster visual assessment of game state -- **Confidence**: Clear visual references reduce placement errors - -### 2. Visual Polish -- **Professional**: Matches commercial Tetris game standards -- **Modern**: Clean, organized interface layout -- **Consistent**: Maintains established visual design language - -### 3. Accessibility -- **Clarity**: Better visual separation for all users -- **Readability**: Improved layout hierarchy -- **Comfort**: Reduced visual strain during extended play - -## Conclusion -The layout improvements successfully address the visual overlap issue with the next piece preview and add essential grid lines for better gameplay precision. The changes maintain the responsive design system while providing a more polished and professional gaming experience. - -## Status: ✅ COMPLETED -- Next piece repositioned higher above main grid -- Subtle grid lines added to main game board -- Layout maintains responsive design and perfect centering -- All existing functionality preserved with enhanced visual clarity diff --git a/LOADING_PROGRESS_FIX.md b/LOADING_PROGRESS_FIX.md deleted file mode 100644 index 9f23312..0000000 --- a/LOADING_PROGRESS_FIX.md +++ /dev/null @@ -1,138 +0,0 @@ -# Loading Progress Fix - Issue Resolution - -## Problem Identified -The loading progress was reaching 157% instead of stopping at 100%. This was caused by a mismatch between: -- **Expected tracks**: 11 (hardcoded `totalTracks = 11`) -- **Actual music files**: 24 total files (but only 11 numbered music tracks) - -## Root Cause Analysis - -### File Structure in `assets/music/`: -``` -Numbered Music Tracks (Background Music): -- music001.mp3 through music011.mp3 (11 files) - -Sound Effect Files: -- amazing.mp3, boom_tetris.mp3, great_move.mp3, impressive.mp3 -- keep_that_ryhtm.mp3, lets_go.mp3, nice_combo.mp3, smooth_clear.mp3 -- triple_strike.mp3, well_played.mp3, wonderful.mp3, you_fire.mp3 -(13 sound effect files) -``` - -### Issue Details: -1. **Hardcoded Count**: `totalTracks` was fixed at 11 -2. **Audio Loading**: The system was actually loading more than 11 files -3. **Progress Calculation**: `currentTrackLoading / totalTracks * 0.7` exceeded 0.7 when `currentTrackLoading > 11` -4. **Result**: Progress went beyond 100% (up to ~157%) - -## Solution Implemented - -### 1. Dynamic Track Counting -```cpp -// BEFORE: Fixed count -const int totalTracks = 11; - -// AFTER: Dynamic detection -int totalTracks = 0; // Will be set dynamically based on actual files -``` - -### 2. File Detection Logic -```cpp -// Count actual numbered music files (music001.mp3, music002.mp3, etc.) -totalTracks = 0; -for (int i = 1; i <= 100; ++i) { - char buf[64]; - std::snprintf(buf, sizeof(buf), "assets/music/music%03d.mp3", i); - - // Check if file exists - SDL_IOStream* file = SDL_IOFromFile(buf, "rb"); - if (file) { - SDL_CloseIO(file); - totalTracks++; - } else { - break; // No more consecutive files - } -} -``` - -### 3. Progress Calculation Safety -```cpp -// BEFORE: Could exceed 100% -double musicProgress = musicLoaded ? 0.7 : (double)currentTrackLoading / totalTracks * 0.7; - -// AFTER: Capped at maximum values -double musicProgress = 0.0; -if (totalTracks > 0) { - musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7); -} - -// Additional safety check -loadingProgress = std::min(1.0, loadingProgress); -``` - -## Technical Verification - -### Test Results: -✅ **Track Detection**: Correctly identifies 11 numbered music tracks -✅ **Progress Calculation**: 0/11 → 11/11 (never exceeds denominator) -✅ **Loading Phases**: 20% (assets) + 70% (music) + 10% (init) = 100% max -✅ **Safety Bounds**: `std::min(1.0, loadingProgress)` prevents overflow -✅ **Game Launch**: Smooth transition from loading to menu at exactly 100% - -### Debug Output (Removed in Final): -``` -Found 11 music tracks to load -Loading progress: 0/11 tracks loaded -... -Loading progress: 11/11 tracks loaded -All music tracks loaded successfully! -``` - -## Benefits of the Fix - -### 1. Accurate Progress Display -- **Before**: Could show 157% (confusing and broken) -- **After**: Always stops exactly at 100% (professional and accurate) - -### 2. Dynamic Adaptability -- **Before**: Hardcoded for exactly 11 tracks -- **After**: Automatically adapts to any number of numbered music tracks - -### 3. Asset Separation -- **Music Tracks**: Only numbered files (`music001.mp3` - `music011.mp3`) for background music -- **Sound Effects**: Named files (`amazing.mp3`, `boom_tetris.mp3`, etc.) handled separately - -### 4. Robust Error Handling -- **File Detection**: Safe file existence checking with proper resource cleanup -- **Progress Bounds**: Multiple safety checks prevent mathematical overflow -- **Loading Logic**: Graceful handling of missing or incomplete file sequences - -## Code Quality Improvements - -### 1. Resource Management -```cpp -SDL_IOStream* file = SDL_IOFromFile(buf, "rb"); -if (file) { - SDL_CloseIO(file); // Proper cleanup - totalTracks++; -} -``` - -### 2. Mathematical Safety -```cpp -loadingProgress = std::min(1.0, loadingProgress); // Never exceed 100% -``` - -### 3. Clear Phase Separation -```cpp -// Phase 1: Assets (20%) + Phase 2: Music (70%) + Phase 3: Init (10%) = 100% -``` - -## Conclusion -The loading progress now correctly shows 0% → 100% progression, with proper file detection, safe mathematical calculations, and clean separation between background music tracks and sound effect files. The system is now robust and will adapt automatically if music tracks are added or removed. - -## Status: ✅ RESOLVED -- Loading progress fixed: Never exceeds 100% -- Dynamic track counting: Adapts to actual file count -- Code quality: Improved safety and resource management -- User experience: Professional loading screen with accurate progress diff --git a/README.md b/README.md new file mode 100644 index 0000000..9846bf5 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +Spacetris SDL3 +============= + +A native C++20 SDL3-based Tetris-style game. + +Quick Start (Windows) +- Install vcpkg and packages: `vcpkg install sdl3 sdl3-ttf --triplet=x64-windows` +- Configure: `cmake -S . -B build-msvc -DCMAKE_BUILD_TYPE=Debug` +- Build: `cmake --build build-msvc --config Debug` +- Run (helper): `. +build-debug-and-run.ps1` or run `build-msvc\Debug\spacetris.exe` + +Production Packaging +- Quick package (uses existing build): `. +package-quick.ps1` +- Full production (clean Release build + package): `. +build-production.ps1 -Clean` + +Tests +- Unit tests target: `spacetris_tests` → exe `spacetris_tests.exe` +- Run tests: configure+build then `ctest -C Debug` or run the test exe directly + +Where to look +- Main app sources: `src/` (entry `src/main.cpp`, app `src/app`) +- Build control: `CMakeLists.txt` and `cmake/ProductionBuild.cmake` +- Packaging helpers: `build-production.ps1`, `package-quick.ps1` + +Notes +- The canonical executable name is `spacetris` (`spacetris.exe` on Windows). +- Assets live in `assets/` and are copied into the distribution package. + +If you want, I can: +- Run a Debug build and confirm the test executable name, +- Replace remaining legacy "tetris" tokens across generated files and docs. diff --git a/SOUND_EFFECTS_IMPLEMENTATION.md b/SOUND_EFFECTS_IMPLEMENTATION.md deleted file mode 100644 index dd4c072..0000000 --- a/SOUND_EFFECTS_IMPLEMENTATION.md +++ /dev/null @@ -1,118 +0,0 @@ -# Sound Effects Implementation - -## Overview -This document describes the sound effects system implemented in the SDL C++ Tetris project, ported from the JavaScript version. - -## Sound Effects Implemented - -### 1. Line Clear Sounds -- **Basic Line Clear**: `clear_line.wav` - Plays for all line clears (1-4 lines) -- **Voice Feedback**: Plays after the basic sound with a slight delay - -### 2. Voice Lines by Line Count - -#### Single Line Clear -- No specific voice lines (only basic clear sound plays) - -#### Double Line Clear (2 lines) -- `nice_combo.mp3` - "Nice combo" -- `you_fire.mp3` - "You're on fire" -- `well_played.mp3` - "Well played" -- `keep_that_ryhtm.mp3` - "Keep that rhythm" (note: typo preserved from original) - -#### Triple Line Clear (3 lines) -- `great_move.mp3` - "Great move" -- `smooth_clear.mp3` - "Smooth clear" -- `impressive.mp3` - "Impressive" -- `triple_strike.mp3` - "Triple strike" - -#### Tetris (4 lines) -- `amazing.mp3` - "Amazing" -- `you_re_unstoppable.mp3` - "You're unstoppable" -- `boom_tetris.mp3` - "Boom! Tetris!" -- `wonderful.mp3` - "Wonderful" - -### 3. Level Up Sound -- `lets_go.mp3` - "Let's go" - Plays when the player advances to a new level - -## Implementation Details - -### Core Classes -1. **SoundEffect**: Handles individual sound file loading and playback - - Supports both WAV and MP3 formats - - Uses SDL3 audio streams for playback - - Volume control per sound effect - -2. **SoundEffectManager**: Manages all sound effects - - Singleton pattern for global access - - Random selection from sound groups - - Master volume and enable/disable controls - -### Audio Pipeline -1. **Loading**: Sound files are loaded during game initialization - - WAV files use SDL's native loading - - MP3 files use Windows Media Foundation (Windows only) - - All audio is converted to 16-bit stereo 44.1kHz - -2. **Playback**: Uses SDL3 audio streams - - Each sound effect can be played independently - - Volume mixing with master volume control - - Non-blocking playback for game responsiveness - -### Integration with Game Logic -- **Line Clear Callback**: Game class triggers sound effects when lines are cleared -- **Level Up Callback**: Triggered when player advances levels -- **Random Selection**: Multiple voice lines for same event are randomly selected - -### Controls -- **M Key**: Toggle background music on/off -- **S Key**: Toggle sound effects on/off -- Settings popup shows current status of both music and sound effects - -### JavaScript Compatibility -The implementation matches the JavaScript version exactly: -- Same sound files used -- Same triggering conditions (line counts, level ups) -- Same random selection behavior for voice lines -- Same volume levels and mixing - -## Audio Files Structure -``` -assets/music/ -├── clear_line.wav # Basic line clear sound -├── nice_combo.mp3 # Double line voice -├── you_fire.mp3 # Double line voice -├── well_played.mp3 # Double line voice -├── keep_that_ryhtm.mp3 # Double line voice (typo preserved) -├── great_move.mp3 # Triple line voice -├── smooth_clear.mp3 # Triple line voice -├── impressive.mp3 # Triple line voice -├── triple_strike.mp3 # Triple line voice -├── amazing.mp3 # Tetris voice -├── you_re_unstoppable.mp3 # Tetris voice -├── boom_tetris.mp3 # Tetris voice -├── wonderful.mp3 # Tetris voice -└── lets_go.mp3 # Level up sound -``` - -## Technical Notes - -### Platform Support -- **Windows**: Full MP3 support via Windows Media Foundation -- **Other platforms**: WAV support only (MP3 requires additional libraries) - -### Performance -- All sounds are pre-loaded during initialization -- Minimal CPU overhead during gameplay -- SDL3 handles audio mixing and buffering - -### Memory Usage -- Sound effects are kept in memory for instant playback -- Total memory usage approximately 50-100MB for all effects -- Memory is freed on application shutdown - -## Future Enhancements -- Add sound effects for piece placement/movement -- Implement positional audio for stereo effects -- Add configurable volume levels per sound type -- Support for additional audio formats (OGG, FLAC) diff --git a/SPAWN_AND_FONT_IMPROVEMENTS.md b/SPAWN_AND_FONT_IMPROVEMENTS.md deleted file mode 100644 index e73de29..0000000 --- a/SPAWN_AND_FONT_IMPROVEMENTS.md +++ /dev/null @@ -1,182 +0,0 @@ -# Piece Spawning and Font Enhancement - Implementation Report - -## Overview -Fixed piece spawning position to appear within the grid boundaries and updated the gameplay UI to consistently use the PressStart2P retro pixel font for authentic Tetris aesthetics. - -## Changes Made - -### 1. Piece Spawning Position Fix - -**Problem**: New pieces were spawning at `y = -2`, causing them to appear above the visible grid area, which is non-standard for Tetris gameplay. - -**Solution**: Updated spawn position to `y = 0` (top of the grid) - -#### Files Modified: -- **`src/Game.cpp`** - `spawn()` function -- **`src/Game.cpp`** - `holdCurrent()` function - -#### Code Changes: -```cpp -// BEFORE: Pieces spawn above grid -cur = Piece{ bag.back(), 0, 3, -2 }; -nextPiece = Piece{ bag.back(), 0, 3, -2 }; - -// AFTER: Pieces spawn within grid -cur = Piece{ bag.back(), 0, 3, 0 }; // Spawn at top of visible grid -nextPiece = Piece{ bag.back(), 0, 3, 0 }; -``` - -#### Hold Function Updates: -```cpp -// BEFORE: Hold pieces reset above grid -hold.x = 3; hold.y = -2; hold.rot = 0; -cur.x = 3; cur.y = -2; cur.rot = 0; - -// AFTER: Hold pieces reset within grid -hold.x = 3; hold.y = 0; hold.rot = 0; // Within grid boundaries -cur.x = 3; cur.y = 0; cur.rot = 0; -``` - -### 2. Font System Enhancement - -**Goal**: Replace FreeSans font with PressStart2P for authentic retro gaming experience - -#### Updated UI Elements: -- **Next Piece Preview**: "NEXT" label -- **Statistics Panel**: "BLOCKS" header and piece counts -- **Score Panel**: "SCORE", "LINES", "LEVEL" headers and values -- **Progress Indicators**: "NEXT LVL" and line count -- **Time Display**: "TIME" header and timer -- **Hold System**: "HOLD" label -- **Pause Screen**: "PAUSED" text and resume instructions - -#### Font Scale Adjustments: -```cpp -// Optimized scales for PressStart2P readability -Headers (SCORE, LINES, etc.): 1.0f scale -Values (numbers, counts): 0.8f scale -Small labels: 0.7f scale -Pause text: 2.0f scale -``` - -#### Before vs After: -``` -BEFORE (FreeSans): AFTER (PressStart2P): -┌─────────────────┐ ┌─────────────────┐ -│ SCORE │ │ SCORE │ ← Retro pixel font -│ 12,400 │ │ 12,400 │ ← Monospace numbers -│ LINES │ │ LINES │ ← Consistent styling -│ 042 │ │ 042 │ ← Authentic feel -└─────────────────┘ └─────────────────┘ -``` - -## Technical Benefits - -### 1. Standard Tetris Behavior -- **Proper Spawning**: Pieces now appear at the standard Tetris spawn position -- **Visible Entry**: Players can see pieces entering the game area -- **Collision Detection**: Improved accuracy for top-of-grid scenarios -- **Game Over Logic**: Clearer indication when pieces can't spawn - -### 2. Enhanced Visual Consistency -- **Unified Typography**: All gameplay elements use the same retro font -- **Authentic Aesthetics**: Matches classic arcade Tetris appearance -- **Professional Polish**: Consistent branding throughout the game -- **Improved Readability**: Monospace numbers for better score tracking - -### 3. Gameplay Improvements -- **Predictable Spawning**: Pieces always appear in the expected location -- **Strategic Planning**: Players can plan for pieces entering at the top -- **Reduced Confusion**: No more pieces appearing from above the visible area -- **Standard Experience**: Matches expectations from other Tetris games - -## Implementation Details - -### Spawn Position Logic -```cpp -// Standard Tetris spawning behavior: -// - X position: 3 (center of 10-wide grid) -// - Y position: 0 (top row of visible grid) -// - Rotation: 0 (default orientation) - -Piece newPiece = { pieceType, 0, 3, 0 }; -``` - -### Font Rendering Optimization -```cpp -// Consistent retro UI with optimized scales -pixelFont.draw(renderer, x, y, "SCORE", 1.0f, goldColor); // Headers -pixelFont.draw(renderer, x, y, "12400", 0.8f, whiteColor); // Values -pixelFont.draw(renderer, x, y, "5 LINES", 0.7f, whiteColor); // Details -``` - -## Testing Results - -### 1. Spawn Position Verification -✅ **New pieces appear at grid top**: Visible within game boundaries -✅ **Hold functionality**: Swapped pieces spawn correctly -✅ **Game over detection**: Proper collision when grid is full -✅ **Visual clarity**: No confusion about piece entry point - -### 2. Font Rendering Validation -✅ **PressStart2P loading**: Font loads correctly from assets -✅ **Text readability**: All UI elements clearly visible -✅ **Scale consistency**: Proper proportions across different text sizes -✅ **Color preservation**: Maintains original color scheme -✅ **Performance**: No rendering performance impact - -### 3. User Experience Testing -✅ **Gameplay flow**: Natural piece entry feels intuitive -✅ **Visual appeal**: Retro aesthetic enhances game experience -✅ **Information clarity**: Statistics and scores easily readable -✅ **Professional appearance**: Polished, consistent UI design - -## Visual Comparison - -### Piece Spawning: -``` -BEFORE: [ ■■ ] ← Pieces appear above grid (confusing) - [ ] - [████████] ← Actual game grid - [████████] - -AFTER: [ ■■ ] ← Pieces appear within grid (clear) - [████████] ← Visible entry point - [████████] -``` - -### Font Aesthetics: -``` -BEFORE (FreeSans): AFTER (PressStart2P): -Modern, clean font 8-bit pixel perfect font -Variable spacing Monospace alignment -Smooth curves Sharp pixel edges -Generic appearance Authentic retro feel -``` - -## Benefits Summary - -### 1. Gameplay Standards -- **Tetris Compliance**: Matches standard Tetris piece spawning behavior -- **Player Expectations**: Familiar experience for Tetris players -- **Strategic Depth**: Proper visibility of incoming pieces - -### 2. Visual Enhancement -- **Retro Authenticity**: True 8-bit arcade game appearance -- **Consistent Branding**: Unified typography throughout gameplay -- **Professional Polish**: Commercial-quality visual presentation - -### 3. User Experience -- **Clarity**: Clear piece entry and movement -- **Immersion**: Enhanced retro gaming atmosphere -- **Accessibility**: Improved text readability and information hierarchy - -## Status: ✅ COMPLETED -- Piece spawning fixed: Y position changed from -2 to 0 -- Font system updated: PressStart2P implemented for gameplay UI -- Hold functionality: Updated to use correct spawn positions -- Visual consistency: All gameplay text uses retro pixel font -- Testing validated: Proper spawning behavior and enhanced aesthetics - -## Conclusion -The game now provides a proper Tetris experience with pieces spawning within the visible grid and a consistently retro visual presentation that enhances the classic arcade gaming atmosphere. diff --git a/SPAWN_AND_GRID_FIXES.md b/SPAWN_AND_GRID_FIXES.md deleted file mode 100644 index 6e5a4dc..0000000 --- a/SPAWN_AND_GRID_FIXES.md +++ /dev/null @@ -1,167 +0,0 @@ -# Spawn Position and Grid Line Alignment Fix - -## Overview -Fixed piece spawning to start one line higher (in the 2nd visible row) and corrected grid line alignment issues that occurred during window resizing and fullscreen mode. - -## Issues Resolved - -### 1. Piece Spawn Position Adjustment - -**Problem**: Pieces were spawning at the very top of the grid, user requested them to start one line higher (in the 2nd visible line). - -**Solution**: Changed spawn Y position from `0` to `-1` - -#### Code Changes: -```cpp -// BEFORE: Pieces spawn at top row (y = 0) -cur = Piece{ bag.back(), 0, 3, 0 }; -nextPiece = Piece{ bag.back(), 0, 3, 0 }; - -// AFTER: Pieces spawn one line higher (y = -1, appears in 2nd line) -cur = Piece{ bag.back(), 0, 3, -1 }; -nextPiece = Piece{ bag.back(), 0, 3, -1 }; -``` - -#### Files Modified: -- **`src/Game.cpp`** - `spawn()` function -- **`src/Game.cpp`** - `holdCurrent()` function - -**Result**: New pieces now appear in the 2nd visible line of the grid, giving players slightly more time to react and plan placement. - -### 2. Grid Line Alignment Fix - -**Problem**: Grid lines were appearing offset (to the right) instead of being properly centered within the game grid, especially noticeable during window resizing and fullscreen mode. - -**Root Cause**: Double application of content offsets - the grid position (`gridX`, `gridY`) already included content offsets, but the grid line drawing code was adding them again. - -#### Before (Incorrect): -```cpp -// Grid lines were offset due to double content offset application -float lineX = gridX + x * finalBlockSize + contentOffsetX; // ❌ contentOffsetX added twice -SDL_RenderLine(renderer, lineX, gridY + contentOffsetY, lineX, gridY + GRID_H + contentOffsetY); -``` - -#### After (Corrected): -```cpp -// Grid lines properly aligned within the grid boundaries -float lineX = gridX + x * finalBlockSize; // ✅ contentOffsetX already in gridX -SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); -``` - -## Technical Details - -### Spawn Position Logic -```cpp -// Standard Tetris spawning with one-line buffer: -// - X position: 3 (center of 10-wide grid) -// - Y position: -1 (one line above top visible row) -// - Rotation: 0 (default orientation) - -Piece newPiece = { pieceType, 0, 3, -1 }; -``` - -### Grid Line Coordinate System -```cpp -// Proper coordinate calculation: -// gridX and gridY already include contentOffsetX/Y for centering -// Grid lines should be relative to these pre-offset coordinates - -// Vertical lines at each column boundary -for (int x = 1; x < Game::COLS; ++x) { - float lineX = gridX + x * finalBlockSize; - SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); -} - -// Horizontal lines at each row boundary -for (int y = 1; y < Game::ROWS; ++y) { - float lineY = gridY + y * finalBlockSize; - SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY); -} -``` - -## Benefits - -### 1. Improved Gameplay Experience -- **Better Timing**: Pieces appear one line higher, giving players more reaction time -- **Strategic Advantage**: Slightly more space to plan piece placement -- **Standard Feel**: Matches many classic Tetris implementations - -### 2. Visual Consistency -- **Proper Grid Alignment**: Grid lines now perfectly align with cell boundaries -- **Responsive Design**: Grid lines maintain proper alignment during window resize -- **Fullscreen Compatibility**: Grid lines stay centered in fullscreen mode -- **Professional Appearance**: Clean, precise visual grid structure - -### 3. Technical Robustness -- **Coordinate System**: Simplified and corrected coordinate calculations -- **Responsive Layout**: Grid lines properly scale with dynamic block sizes -- **Window Management**: Handles all window states (windowed, maximized, fullscreen) - -## Testing Results - -### 1. Spawn Position Verification -✅ **Visual Confirmation**: New pieces appear in 2nd visible line -✅ **Gameplay Feel**: Improved reaction time and strategic planning -✅ **Hold Function**: Held pieces also spawn at correct position -✅ **Game Flow**: Natural progression from spawn to placement - -### 2. Grid Line Alignment Testing -✅ **Windowed Mode**: Grid lines perfectly centered in normal window -✅ **Resize Behavior**: Grid lines stay aligned during window resize -✅ **Fullscreen Mode**: Grid lines maintain center alignment in fullscreen -✅ **Dynamic Scaling**: Grid lines scale correctly with different block sizes - -### 3. Cross-Resolution Validation -✅ **Multiple Resolutions**: Tested across various window sizes -✅ **Aspect Ratios**: Maintains alignment in different aspect ratios -✅ **Scaling Factors**: Proper alignment at all logical scale factors - -## Visual Comparison - -### Spawn Position: -``` -BEFORE: [████████] ← Pieces spawn here (top line) - [ ] - [████████] - -AFTER: [ ] ← Piece appears here first - [████████] ← Then moves into visible grid - [████████] -``` - -### Grid Line Alignment: -``` -BEFORE (Offset): AFTER (Centered): -┌─────────────┐ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ -│ ┬─┬─┬─┬─┬─┬│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ ← Perfect alignment -│ ┼─┼─┼─┼─┼─┼│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ -│ ┼─┼─┼─┼─┼─┼│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ -└─────────────┘ -↑ Lines offset right ↑ Lines perfectly centered -``` - -## Impact on User Experience - -### 1. Gameplay Improvements -- **Reaction Time**: Extra moment to assess and plan piece placement -- **Strategic Depth**: More time for complex piece rotations and positioning -- **Difficulty Balance**: Slightly more forgiving spawn timing - -### 2. Visual Polish -- **Professional Grid**: Clean, precise cell boundaries -- **Consistent Alignment**: Grid maintains perfection across all window states -- **Enhanced Readability**: Clear visual reference for piece placement - -### 3. Technical Quality -- **Responsive Design**: Proper scaling and alignment in all scenarios -- **Code Quality**: Simplified and more maintainable coordinate system -- **Cross-Platform**: Consistent behavior regardless of display configuration - -## Status: ✅ COMPLETED -- Spawn position adjusted: Y coordinate moved from 0 to -1 -- Grid line alignment fixed: Removed duplicate content offset application -- Testing validated: Proper alignment in windowed, resized, and fullscreen modes -- User experience enhanced: Better gameplay timing and visual precision - -## Conclusion -Both issues have been successfully resolved. The game now provides an optimal spawn experience with pieces appearing in the 2nd visible line, while the grid lines maintain perfect alignment regardless of window state or size changes. These improvements enhance both the gameplay experience and visual quality of the Tetris game. diff --git a/UPGRADES.md b/UPGRADES.md index 523ea5d..da5ea19 100644 --- a/UPGRADES.md +++ b/UPGRADES.md @@ -1,12 +1,14 @@ -# Tetris — Upgrade Roadmap +# Spacetris — Upgrade Roadmap This document lists recommended code, architecture, tooling, and runtime upgrades for the native SDL3 Tetris project. Items are grouped, prioritized, and mapped to target files and effort estimates so you can plan incremental work. ## Short plan + - Audit surface area and break tasks into small, testable PRs. - Start with low-risk safety and build improvements, then refactors (Gravity/Scoring/Board), then tests/CI, then UX polish and optional features. ## Checklist (high level) + - [x] Make NES gravity table constant (constexpr) and move per-level multipliers out of a mutable global - Note: FRAMES_TABLE is now immutable inside `GravityManager`; per-level multipliers are instance-scoped in `GravityManager`. - [x] Extract GravityManager (SRP) @@ -18,7 +20,7 @@ This document lists recommended code, architecture, tooling, and runtime upgrade - [x] Replace ad-hoc printf with SDL_Log or injected Logger service - Note: majority of printf/fprintf debug prints were replaced with `SDL_Log*` calls; a quick grep audit is recommended to find any remaining ad-hoc prints. - [x] Add unit tests (gravity conversion, level progression, line clear behavior) - - Note: a small test runner (`tests/GravityTests.cpp`) and the `tetris_tests` CMake target were added and run locally; gravity tests pass (see build-msvc test run). Converting to broader Catch2 suites is optional. + - Note: a small test runner (`tests/GravityTests.cpp`) and the `spacetris_tests` CMake target were added and run locally; gravity tests pass (see build-msvc test run). Converting to broader Catch2 suites is optional. - [ ] Add CI (build + tests) and code style checks - [ ] Improve input hit-testing for level popup and scalable UI - [ ] Add defensive guards (clamps, null checks) and const-correctness @@ -27,6 +29,7 @@ This document lists recommended code, architecture, tooling, and runtime upgrade --- ## Rationale & Goals + - Improve maintainability: split `Game` responsibilities into focused classes (SRP) so logic is testable. - Improve safety: remove mutable shared state (globals/`extern`) and minimize hidden side-effects. - Improve tuning: per-level and global gravity tuning must be instance-local and debug-friendly. @@ -37,6 +40,7 @@ This document lists recommended code, architecture, tooling, and runtime upgrade ## Detailed tasks (prioritized) ### 1) Safety & small win (Low risk — 1–3 hours) + - Make the NES frames-per-cell table `constexpr` and immutable. - File: `src/Game.cpp` (reverse any mutable anonymous namespace table changes) - Why: avoids accidental global mutation; makes compute deterministic. @@ -48,18 +52,21 @@ This document lists recommended code, architecture, tooling, and runtime upgrade Deliverable: small patch to `Game.h`/`Game.cpp` and a rebuild verification. ### 2) Replace ad-hoc logging (Low risk — 0.5–1 hour) + - Replace `printf` debug prints with `SDL_Log` or an injected `Logger` interface. - Prefer a build-time `#ifdef DEBUG` or runtime verbosity flag so release builds are quiet. Files: `src/Game.cpp`, any file using printf for debug. ### 3) Remove fragile globals / externs (Low risk — 1–2 hours) + - Ensure all textures, fonts and shared resources are passed via `StateContext& ctx`. - Remove leftover `extern SDL_Texture* backgroundTex` or similar; make call-sites accept `SDL_Texture*` or read from `ctx`. Files: `src/main.cpp`, `src/states/MenuState.cpp`, other states. ### 4) Extract GravityManager (Medium risk — 2–6 hours) + - Create `src/core/GravityManager.h|cpp` that encapsulates - the `constexpr` NES frames table (read-only) - per-instance multipliers @@ -69,6 +76,7 @@ Files: `src/main.cpp`, `src/states/MenuState.cpp`, other states. Benefits: easier testing and isolated behavior change for future gravity models. ### 5) Extract Board/Scoring responsibilities (Medium — 4–8 hours) + - Split `Game` into smaller collaborators: - `Board` — raw grid, collision, line detection/clear operations - `Scorer` — scoring rules, combo handling, level progression thresholds @@ -78,6 +86,7 @@ Benefits: easier testing and isolated behavior change for future gravity models. Files: `src/Board.*`, `src/Scorer.*`, `src/PieceController.*`, adjust `Game` to compose them. ### 6) Unit tests & test infrastructure (Medium — 3–6 hours) + - Add Catch2 or GoogleTest to `vcpkg.json` or as `FetchContent` in CMake. - Add tests: - Gravity conversion tests (frames→ms, per-level multipliers, global multiplier) @@ -86,6 +95,7 @@ Files: `src/Board.*`, `src/Scorer.*`, `src/PieceController.*`, adjust `Game` to - Add a `tests/` CMake target and basic CI integration (see next section). ### 7) CI / Build checks (Medium — 2–4 hours) + - Add GitHub Actions to build Debug/Release on Windows and run tests. - Add static analysis: clang-tidy/clang-format or cpplint configured for the project style. - Add a Pre-commit hook to run format checks. @@ -93,6 +103,7 @@ Files: `src/Board.*`, `src/Scorer.*`, `src/PieceController.*`, adjust `Game` to Files: `.github/workflows/build.yml`, `.clang-format`, optional `clang-tidy` config. ### 8) UX and input correctness (Low–Medium — 1–3 hours) + - Update level popup hit-testing to use the same computed button rectangles used for drawing. - Expose a function that returns vector of button bounds from the popup draw logic. - Ensure popup background texture is stretched to the logical viewport and that overlay alpha is constant across window sizes. @@ -101,6 +112,7 @@ Files: `.github/workflows/build.yml`, `.clang-format`, optional `clang-tidy` con Files: `src/main.cpp`, `src/states/MenuState.cpp`. ### 9) Runtime debug knobs (Low — 1 hour) + - Add keys to increase/decrease `gravityGlobalMultiplier` (e.g., `[` and `]`) and reset to 1.0. - Show `gravityGlobalMultiplier` and per-level effective ms on HUD (already partly implemented). - Persist tuning to a small JSON file `settings/debug_tuning.json` (optional). @@ -108,6 +120,7 @@ Files: `src/main.cpp`, `src/states/MenuState.cpp`. Files: `src/Game.h/cpp`, `src/main.cpp` HUD code. ### 10) Defensive & correctness improvements (Low — 2–4 hours) + - Add null checks for SDL_CreateTextureFromSurface and related APIs; log and fallback gracefully. - Convert raw SDL_Texture* ownership to `std::unique_ptr` with custom deleter where appropriate, or centralize lifetime in a `ResourceManager`. - Add `const` qualifiers to getters where possible. @@ -116,6 +129,7 @@ Files: `src/Game.h/cpp`, `src/main.cpp` HUD code. Files: various (`src/*.cpp`, `src/*.h`). ### 11) Packaging & build improvements (Low — 1–2 hours) + - Verify `build-production.ps1` copies all required DLLs for SDL3 and SDL3_ttf from `vcpkg_installed` paths. - Add an automated packaging job to CI that creates a ZIP artifact on release tags. @@ -124,6 +138,7 @@ Files: `build-production.ps1`, `.github/workflows/release.yml`. --- ## Suggested incremental PR plan + 1. Small safety PR: make frames table `constexpr`, move multipliers into `Game` instance, clamp values, and add SDL_Log usage. (1–3 hours) 2. Global cleanup PR: remove `extern` textures and ensure all resource access goes through `StateContext`. (1–2 hours) 3. Add debug knobs & HUD display improvements. (1 hour) @@ -135,6 +150,7 @@ Files: `build-production.ps1`, `.github/workflows/release.yml`. --- ## Recommended quick wins (apply immediately) + - Convert NES frames table to `constexpr` and clamp gravity to >= 1ms. - Replace `printf` with `SDL_Log` for debug output. - Pass `SDL_Texture*` via `StateContext` instead of `extern` globals (you already did this; check for leftovers with `grep` for "extern .*backgroundTex"). @@ -143,6 +159,7 @@ Files: `build-production.ps1`, `.github/workflows/release.yml`. --- ## Example: gravity computation (reference) + ```cpp // gravity constants constexpr double NES_FPS = 60.0988; @@ -162,9 +179,11 @@ double GravityManager::msForLevel(int level) const { --- ## Estimated total effort + - Conservative: 16–36 hours depending on how far you split `Game` and add tests/CI. ## Next steps I can take for you now + - Create a PR that converts the frames table to `constexpr` and moves multipliers into `Game` instance (small patch + build). (I can do this.) - Add a unit-test harness using Catch2 and a small GravityManager test. diff --git a/assets/favicon/favicon.ico b/assets/favicon/favicon.ico index 93f2c3d..0b70629 100644 Binary files a/assets/favicon/favicon.ico and b/assets/favicon/favicon.ico differ diff --git a/assets/fonts/Exo2.ttf b/assets/fonts/Exo2.ttf new file mode 100644 index 0000000..2170b15 Binary files /dev/null and b/assets/fonts/Exo2.ttf differ diff --git a/assets/fonts/Orbitron.ttf b/assets/fonts/Orbitron.ttf new file mode 100644 index 0000000..7d0d5e0 Binary files /dev/null and b/assets/fonts/Orbitron.ttf differ diff --git a/assets/images/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/images/background.bmp b/assets/images/background.bmp deleted file mode 100644 index 0274add..0000000 Binary files a/assets/images/background.bmp and /dev/null differ diff --git a/assets/images/blocks90px_002.png b/assets/images/blocks90px_002.png new file mode 100644 index 0000000..feb5a49 Binary files /dev/null and b/assets/images/blocks90px_002.png differ diff --git a/assets/images/blocks90px_003.png b/assets/images/blocks90px_003.png new file mode 100644 index 0000000..3757a67 Binary files /dev/null and b/assets/images/blocks90px_003.png differ diff --git a/assets/images/cooperate_info.png b/assets/images/cooperate_info.png new file mode 100644 index 0000000..4b586aa Binary files /dev/null and b/assets/images/cooperate_info.png differ diff --git a/assets/images/earth_back.jpg b/assets/images/earth_back.jpg new file mode 100644 index 0000000..c9727b3 Binary files /dev/null and b/assets/images/earth_back.jpg differ diff --git a/assets/images/game_over.bmp b/assets/images/game_over.bmp deleted file mode 100644 index 5df819a..0000000 Binary files a/assets/images/game_over.bmp and /dev/null differ diff --git a/assets/images/game_over.png b/assets/images/game_over.png deleted file mode 100644 index 85397c8..0000000 Binary files a/assets/images/game_over.png and /dev/null differ diff --git a/assets/images/gameplay.bmp b/assets/images/gameplay.bmp deleted file mode 100644 index 348985a..0000000 Binary files a/assets/images/gameplay.bmp and /dev/null differ diff --git a/assets/images/gameplay.webp b/assets/images/gameplay.webp deleted file mode 100644 index 94909bc..0000000 Binary files a/assets/images/gameplay.webp and /dev/null differ diff --git a/assets/images/gameplay1.bmp b/assets/images/gameplay1.bmp deleted file mode 100644 index 85162a1..0000000 Binary files a/assets/images/gameplay1.bmp and /dev/null differ diff --git a/assets/images/gameplay1.jpg b/assets/images/gameplay1.jpg deleted file mode 100644 index 7fabbbc..0000000 Binary files a/assets/images/gameplay1.jpg and /dev/null differ diff --git a/assets/images/gameplay2.bmp b/assets/images/gameplay2.bmp deleted file mode 100644 index ed52e99..0000000 Binary files a/assets/images/gameplay2.bmp and /dev/null differ diff --git a/assets/images/gameplay2.jpg b/assets/images/gameplay2.jpg deleted file mode 100644 index 0081262..0000000 Binary files a/assets/images/gameplay2.jpg and /dev/null differ diff --git a/assets/images/gameplay3.bmp b/assets/images/gameplay3.bmp deleted file mode 100644 index 10ad47c..0000000 Binary files a/assets/images/gameplay3.bmp and /dev/null differ diff --git a/assets/images/gameplay3.jpg b/assets/images/gameplay3.jpg deleted file mode 100644 index 82fd36f..0000000 Binary files a/assets/images/gameplay3.jpg and /dev/null differ diff --git a/assets/images/hold_panel.png b/assets/images/hold_panel.png new file mode 100644 index 0000000..191ef4a Binary files /dev/null and b/assets/images/hold_panel.png differ diff --git a/assets/images/levels/level0.jpg b/assets/images/levels/level0.jpg new file mode 100644 index 0000000..c2a0cf5 Binary files /dev/null and b/assets/images/levels/level0.jpg differ diff --git a/assets/images/levels/level1.jpg b/assets/images/levels/level1.jpg new file mode 100644 index 0000000..43d10a7 Binary files /dev/null and b/assets/images/levels/level1.jpg differ diff --git a/assets/images/levels/level10.jpg b/assets/images/levels/level10.jpg new file mode 100644 index 0000000..8b62e21 Binary files /dev/null and b/assets/images/levels/level10.jpg differ diff --git a/assets/images/levels/level11.jpg b/assets/images/levels/level11.jpg new file mode 100644 index 0000000..1588979 Binary files /dev/null and b/assets/images/levels/level11.jpg differ diff --git a/assets/images/levels/level12.jpg b/assets/images/levels/level12.jpg new file mode 100644 index 0000000..f553a7e Binary files /dev/null and b/assets/images/levels/level12.jpg differ diff --git a/assets/images/levels/level13.jpg b/assets/images/levels/level13.jpg new file mode 100644 index 0000000..f505cf4 Binary files /dev/null and b/assets/images/levels/level13.jpg differ diff --git a/assets/images/levels/level14.jpg b/assets/images/levels/level14.jpg new file mode 100644 index 0000000..1c9801a Binary files /dev/null and b/assets/images/levels/level14.jpg differ diff --git a/assets/images/levels/level15.jpg b/assets/images/levels/level15.jpg new file mode 100644 index 0000000..4de466b Binary files /dev/null and b/assets/images/levels/level15.jpg differ diff --git a/assets/images/levels/level16.jpg b/assets/images/levels/level16.jpg new file mode 100644 index 0000000..256393d Binary files /dev/null and b/assets/images/levels/level16.jpg differ diff --git a/assets/images/levels/level17.jpg b/assets/images/levels/level17.jpg new file mode 100644 index 0000000..f34534d Binary files /dev/null and b/assets/images/levels/level17.jpg differ diff --git a/assets/images/levels/level18.jpg b/assets/images/levels/level18.jpg new file mode 100644 index 0000000..726b839 Binary files /dev/null and b/assets/images/levels/level18.jpg differ diff --git a/assets/images/levels/level19.jpg b/assets/images/levels/level19.jpg new file mode 100644 index 0000000..cb2a64d Binary files /dev/null and b/assets/images/levels/level19.jpg differ diff --git a/assets/images/levels/level2.jpg b/assets/images/levels/level2.jpg new file mode 100644 index 0000000..e98da88 Binary files /dev/null and b/assets/images/levels/level2.jpg differ diff --git a/assets/images/levels/level20.jpg b/assets/images/levels/level20.jpg new file mode 100644 index 0000000..e2cc918 Binary files /dev/null and b/assets/images/levels/level20.jpg differ diff --git a/assets/images/levels/level21.jpg b/assets/images/levels/level21.jpg new file mode 100644 index 0000000..dd03a6b Binary files /dev/null and b/assets/images/levels/level21.jpg differ diff --git a/assets/images/levels/level22.jpg b/assets/images/levels/level22.jpg new file mode 100644 index 0000000..1ec27fe Binary files /dev/null and b/assets/images/levels/level22.jpg differ diff --git a/assets/images/levels/level23.jpg b/assets/images/levels/level23.jpg new file mode 100644 index 0000000..cf7aa39 Binary files /dev/null and b/assets/images/levels/level23.jpg differ diff --git a/assets/images/levels/level24.jpg b/assets/images/levels/level24.jpg new file mode 100644 index 0000000..f457f1e Binary files /dev/null and b/assets/images/levels/level24.jpg differ diff --git a/assets/images/levels/level25.jpg b/assets/images/levels/level25.jpg new file mode 100644 index 0000000..cc83029 Binary files /dev/null and b/assets/images/levels/level25.jpg differ diff --git a/assets/images/levels/level26.jpg b/assets/images/levels/level26.jpg new file mode 100644 index 0000000..daf8d82 Binary files /dev/null and b/assets/images/levels/level26.jpg differ diff --git a/assets/images/levels/level27.jpg b/assets/images/levels/level27.jpg new file mode 100644 index 0000000..335fde2 Binary files /dev/null and b/assets/images/levels/level27.jpg differ diff --git a/assets/images/levels/level28.jpg b/assets/images/levels/level28.jpg new file mode 100644 index 0000000..5808398 Binary files /dev/null and b/assets/images/levels/level28.jpg differ diff --git a/assets/images/levels/level29.jpg b/assets/images/levels/level29.jpg new file mode 100644 index 0000000..e2d167d Binary files /dev/null and b/assets/images/levels/level29.jpg differ diff --git a/assets/images/levels/level3.jpg b/assets/images/levels/level3.jpg new file mode 100644 index 0000000..fb34d25 Binary files /dev/null and b/assets/images/levels/level3.jpg differ diff --git a/assets/images/levels/level30.jpg b/assets/images/levels/level30.jpg new file mode 100644 index 0000000..31451a1 Binary files /dev/null and b/assets/images/levels/level30.jpg differ diff --git a/assets/images/levels/level31.jpg b/assets/images/levels/level31.jpg new file mode 100644 index 0000000..1544ada Binary files /dev/null and b/assets/images/levels/level31.jpg differ diff --git a/assets/images/levels/level32.jpg b/assets/images/levels/level32.jpg new file mode 100644 index 0000000..9765ab6 Binary files /dev/null and b/assets/images/levels/level32.jpg differ diff --git a/assets/images/levels/level4.jpg b/assets/images/levels/level4.jpg new file mode 100644 index 0000000..3a614e4 Binary files /dev/null and b/assets/images/levels/level4.jpg differ diff --git a/assets/images/levels/level5.jpg b/assets/images/levels/level5.jpg new file mode 100644 index 0000000..6b8c544 Binary files /dev/null and b/assets/images/levels/level5.jpg differ diff --git a/assets/images/levels/level6.jpg b/assets/images/levels/level6.jpg new file mode 100644 index 0000000..7882fe9 Binary files /dev/null and b/assets/images/levels/level6.jpg differ diff --git a/assets/images/levels/level7.jpg b/assets/images/levels/level7.jpg new file mode 100644 index 0000000..415b12c Binary files /dev/null and b/assets/images/levels/level7.jpg differ diff --git a/assets/images/levels/level8.jpg b/assets/images/levels/level8.jpg new file mode 100644 index 0000000..584c664 Binary files /dev/null and b/assets/images/levels/level8.jpg differ diff --git a/assets/images/levels/level9.jpg b/assets/images/levels/level9.jpg new file mode 100644 index 0000000..e2c69b4 Binary files /dev/null and b/assets/images/levels/level9.jpg differ diff --git a/assets/images/logo.bmp b/assets/images/logo.bmp deleted file mode 100644 index b7cc6c0..0000000 Binary files a/assets/images/logo.bmp and /dev/null differ diff --git a/assets/images/logo.webp b/assets/images/logo.webp deleted file mode 100644 index de63c6b..0000000 Binary files a/assets/images/logo.webp and /dev/null differ diff --git a/assets/images/logo_small.bmp b/assets/images/logo_small.bmp deleted file mode 100644 index 42557de..0000000 Binary files a/assets/images/logo_small.bmp and /dev/null differ diff --git a/assets/images/logo_small.webp b/assets/images/logo_small.webp deleted file mode 100644 index 18f1133..0000000 Binary files a/assets/images/logo_small.webp and /dev/null differ diff --git a/assets/images/main_background.bmp b/assets/images/main_background.bmp deleted file mode 100644 index 72ef709..0000000 Binary files a/assets/images/main_background.bmp and /dev/null differ diff --git a/assets/images/main_screen.png b/assets/images/main_screen.png new file mode 100644 index 0000000..fb37204 Binary files /dev/null and b/assets/images/main_screen.png differ diff --git a/assets/images/main_screen_old.png b/assets/images/main_screen_old.png new file mode 100644 index 0000000..3f593b8 Binary files /dev/null and b/assets/images/main_screen_old.png differ diff --git a/assets/images/next_panel.png b/assets/images/next_panel.png new file mode 100644 index 0000000..d57947a Binary files /dev/null and b/assets/images/next_panel.png differ diff --git a/assets/images/background.png b/assets/images/old/background.png similarity index 100% rename from assets/images/background.png rename to assets/images/old/background.png diff --git a/assets/images/background.webp b/assets/images/old/background.webp similarity index 100% rename from assets/images/background.webp rename to assets/images/old/background.webp diff --git a/assets/images/blocks001.bmp b/assets/images/old/blocks001.bmp similarity index 100% rename from assets/images/blocks001.bmp rename to assets/images/old/blocks001.bmp diff --git a/assets/images/blocks001.png b/assets/images/old/blocks001.png similarity index 100% rename from assets/images/blocks001.png rename to assets/images/old/blocks001.png diff --git a/assets/images/blocks3.bmp b/assets/images/old/blocks3.bmp similarity index 100% rename from assets/images/blocks3.bmp rename to assets/images/old/blocks3.bmp diff --git a/assets/images/blocks3.png b/assets/images/old/blocks3.png similarity index 100% rename from assets/images/blocks3.png rename to assets/images/old/blocks3.png diff --git a/assets/images/blocks90px_001.bmp b/assets/images/old/blocks90px_001.bmp similarity index 100% rename from assets/images/blocks90px_001.bmp rename to assets/images/old/blocks90px_001.bmp diff --git a/assets/images/main_background.webp b/assets/images/old/main_background.webp similarity index 100% rename from assets/images/main_background.webp rename to assets/images/old/main_background.webp diff --git a/assets/images/tetris_main_back_level0.jpg b/assets/images/old/tetris_main_back_level0.jpg similarity index 100% rename from assets/images/tetris_main_back_level0.jpg rename to assets/images/old/tetris_main_back_level0.jpg diff --git a/assets/images/tetris_main_level_0.webp b/assets/images/old/tetris_main_level_0.webp similarity index 100% rename from assets/images/tetris_main_level_0.webp rename to assets/images/old/tetris_main_level_0.webp diff --git a/assets/images/panel_score.png b/assets/images/panel_score.png new file mode 100644 index 0000000..c0304cc Binary files /dev/null and b/assets/images/panel_score.png differ diff --git a/assets/images/spacetris.png b/assets/images/spacetris.png new file mode 100644 index 0000000..0c7f249 Binary files /dev/null and b/assets/images/spacetris.png differ diff --git a/assets/images/statistics_panel.png b/assets/images/statistics_panel.png new file mode 100644 index 0000000..19a59c1 Binary files /dev/null and b/assets/images/statistics_panel.png differ diff --git a/assets/images/tetris_main_back_level0.bmp b/assets/images/tetris_main_back_level0.bmp deleted file mode 100644 index 0274add..0000000 Binary files a/assets/images/tetris_main_back_level0.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level0x.bmp b/assets/images/tetris_main_back_level0x.bmp deleted file mode 100644 index 58ae7f9..0000000 Binary files a/assets/images/tetris_main_back_level0x.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level1.bmp b/assets/images/tetris_main_back_level1.bmp deleted file mode 100644 index 4f68b82..0000000 Binary files a/assets/images/tetris_main_back_level1.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level1.jpg b/assets/images/tetris_main_back_level1.jpg deleted file mode 100644 index 1e2e3e7..0000000 Binary files a/assets/images/tetris_main_back_level1.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level10.bmp b/assets/images/tetris_main_back_level10.bmp deleted file mode 100644 index 0ef0916..0000000 Binary files a/assets/images/tetris_main_back_level10.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level10.jpg b/assets/images/tetris_main_back_level10.jpg deleted file mode 100644 index 6a0d8a7..0000000 Binary files a/assets/images/tetris_main_back_level10.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level11.bmp b/assets/images/tetris_main_back_level11.bmp deleted file mode 100644 index 5edc3c9..0000000 Binary files a/assets/images/tetris_main_back_level11.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level11.jpg b/assets/images/tetris_main_back_level11.jpg deleted file mode 100644 index 68fbea9..0000000 Binary files a/assets/images/tetris_main_back_level11.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level12.bmp b/assets/images/tetris_main_back_level12.bmp deleted file mode 100644 index 610aff7..0000000 Binary files a/assets/images/tetris_main_back_level12.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level12.jpg b/assets/images/tetris_main_back_level12.jpg deleted file mode 100644 index 2236820..0000000 Binary files a/assets/images/tetris_main_back_level12.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level13.bmp b/assets/images/tetris_main_back_level13.bmp deleted file mode 100644 index 42c52f8..0000000 Binary files a/assets/images/tetris_main_back_level13.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level13.jpg b/assets/images/tetris_main_back_level13.jpg deleted file mode 100644 index 0f85309..0000000 Binary files a/assets/images/tetris_main_back_level13.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level14.bmp b/assets/images/tetris_main_back_level14.bmp deleted file mode 100644 index 6a26df4..0000000 Binary files a/assets/images/tetris_main_back_level14.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level14.jpg b/assets/images/tetris_main_back_level14.jpg deleted file mode 100644 index fb7b55a..0000000 Binary files a/assets/images/tetris_main_back_level14.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level15.bmp b/assets/images/tetris_main_back_level15.bmp deleted file mode 100644 index d4759a3..0000000 Binary files a/assets/images/tetris_main_back_level15.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level15.jpg b/assets/images/tetris_main_back_level15.jpg deleted file mode 100644 index fadc33e..0000000 Binary files a/assets/images/tetris_main_back_level15.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level16.bmp b/assets/images/tetris_main_back_level16.bmp deleted file mode 100644 index 68e6540..0000000 Binary files a/assets/images/tetris_main_back_level16.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level16.jpg b/assets/images/tetris_main_back_level16.jpg deleted file mode 100644 index 0506f1b..0000000 Binary files a/assets/images/tetris_main_back_level16.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level17.bmp b/assets/images/tetris_main_back_level17.bmp deleted file mode 100644 index 2e3aa72..0000000 Binary files a/assets/images/tetris_main_back_level17.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level17.jpg b/assets/images/tetris_main_back_level17.jpg deleted file mode 100644 index 238cfa2..0000000 Binary files a/assets/images/tetris_main_back_level17.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level18.bmp b/assets/images/tetris_main_back_level18.bmp deleted file mode 100644 index ac921ec..0000000 Binary files a/assets/images/tetris_main_back_level18.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level18.jpg b/assets/images/tetris_main_back_level18.jpg deleted file mode 100644 index a347b0a..0000000 Binary files a/assets/images/tetris_main_back_level18.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level19.bmp b/assets/images/tetris_main_back_level19.bmp deleted file mode 100644 index 6d5f762..0000000 Binary files a/assets/images/tetris_main_back_level19.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level19.jpg b/assets/images/tetris_main_back_level19.jpg deleted file mode 100644 index 2159b45..0000000 Binary files a/assets/images/tetris_main_back_level19.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level2.bmp b/assets/images/tetris_main_back_level2.bmp deleted file mode 100644 index 3632a65..0000000 Binary files a/assets/images/tetris_main_back_level2.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level2.jpg b/assets/images/tetris_main_back_level2.jpg deleted file mode 100644 index 276e092..0000000 Binary files a/assets/images/tetris_main_back_level2.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level20.bmp b/assets/images/tetris_main_back_level20.bmp deleted file mode 100644 index 26c88ff..0000000 Binary files a/assets/images/tetris_main_back_level20.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level20.jpg b/assets/images/tetris_main_back_level20.jpg deleted file mode 100644 index a805fcf..0000000 Binary files a/assets/images/tetris_main_back_level20.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level21.bmp b/assets/images/tetris_main_back_level21.bmp deleted file mode 100644 index 8426613..0000000 Binary files a/assets/images/tetris_main_back_level21.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level21.jpg b/assets/images/tetris_main_back_level21.jpg deleted file mode 100644 index b0b3c56..0000000 Binary files a/assets/images/tetris_main_back_level21.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level22.bmp b/assets/images/tetris_main_back_level22.bmp deleted file mode 100644 index d835bbf..0000000 Binary files a/assets/images/tetris_main_back_level22.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level22.jpg b/assets/images/tetris_main_back_level22.jpg deleted file mode 100644 index fa41309..0000000 Binary files a/assets/images/tetris_main_back_level22.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level23.bmp b/assets/images/tetris_main_back_level23.bmp deleted file mode 100644 index 74fc4e5..0000000 Binary files a/assets/images/tetris_main_back_level23.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level23.jpg b/assets/images/tetris_main_back_level23.jpg deleted file mode 100644 index a9f1165..0000000 Binary files a/assets/images/tetris_main_back_level23.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level24.bmp b/assets/images/tetris_main_back_level24.bmp deleted file mode 100644 index 9157494..0000000 Binary files a/assets/images/tetris_main_back_level24.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level24.jpg b/assets/images/tetris_main_back_level24.jpg deleted file mode 100644 index c2e1d1d..0000000 Binary files a/assets/images/tetris_main_back_level24.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level25.bmp b/assets/images/tetris_main_back_level25.bmp deleted file mode 100644 index e30380b..0000000 Binary files a/assets/images/tetris_main_back_level25.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level25.jpg b/assets/images/tetris_main_back_level25.jpg deleted file mode 100644 index ef6c890..0000000 Binary files a/assets/images/tetris_main_back_level25.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level26.bmp b/assets/images/tetris_main_back_level26.bmp deleted file mode 100644 index 7785436..0000000 Binary files a/assets/images/tetris_main_back_level26.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level26.jpg b/assets/images/tetris_main_back_level26.jpg deleted file mode 100644 index 96e9c26..0000000 Binary files a/assets/images/tetris_main_back_level26.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level27.bmp b/assets/images/tetris_main_back_level27.bmp deleted file mode 100644 index fda9b7e..0000000 Binary files a/assets/images/tetris_main_back_level27.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level27.jpg b/assets/images/tetris_main_back_level27.jpg deleted file mode 100644 index effe5a8..0000000 Binary files a/assets/images/tetris_main_back_level27.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level28.bmp b/assets/images/tetris_main_back_level28.bmp deleted file mode 100644 index 5fa0311..0000000 Binary files a/assets/images/tetris_main_back_level28.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level28.jpg b/assets/images/tetris_main_back_level28.jpg deleted file mode 100644 index caa71cd..0000000 Binary files a/assets/images/tetris_main_back_level28.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level29.bmp b/assets/images/tetris_main_back_level29.bmp deleted file mode 100644 index 1d114c3..0000000 Binary files a/assets/images/tetris_main_back_level29.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level29.jpg b/assets/images/tetris_main_back_level29.jpg deleted file mode 100644 index b77e3a5..0000000 Binary files a/assets/images/tetris_main_back_level29.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level3.bmp b/assets/images/tetris_main_back_level3.bmp deleted file mode 100644 index c1f4530..0000000 Binary files a/assets/images/tetris_main_back_level3.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level3.jpg b/assets/images/tetris_main_back_level3.jpg deleted file mode 100644 index 0ead72f..0000000 Binary files a/assets/images/tetris_main_back_level3.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level30.bmp b/assets/images/tetris_main_back_level30.bmp deleted file mode 100644 index b55b99c..0000000 Binary files a/assets/images/tetris_main_back_level30.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level30.jpg b/assets/images/tetris_main_back_level30.jpg deleted file mode 100644 index 6503d7e..0000000 Binary files a/assets/images/tetris_main_back_level30.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level31.bmp b/assets/images/tetris_main_back_level31.bmp deleted file mode 100644 index 796c424..0000000 Binary files a/assets/images/tetris_main_back_level31.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level31.jpg b/assets/images/tetris_main_back_level31.jpg deleted file mode 100644 index c480b0b..0000000 Binary files a/assets/images/tetris_main_back_level31.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level32.bmp b/assets/images/tetris_main_back_level32.bmp deleted file mode 100644 index 65edfec..0000000 Binary files a/assets/images/tetris_main_back_level32.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level32.jpg b/assets/images/tetris_main_back_level32.jpg deleted file mode 100644 index d345caa..0000000 Binary files a/assets/images/tetris_main_back_level32.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level4.bmp b/assets/images/tetris_main_back_level4.bmp deleted file mode 100644 index a68f9ff..0000000 Binary files a/assets/images/tetris_main_back_level4.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level4.jpg b/assets/images/tetris_main_back_level4.jpg deleted file mode 100644 index ce36388..0000000 Binary files a/assets/images/tetris_main_back_level4.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level5.bmp b/assets/images/tetris_main_back_level5.bmp deleted file mode 100644 index d27d2d8..0000000 Binary files a/assets/images/tetris_main_back_level5.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level5.jpg b/assets/images/tetris_main_back_level5.jpg deleted file mode 100644 index 37db308..0000000 Binary files a/assets/images/tetris_main_back_level5.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level6.bmp b/assets/images/tetris_main_back_level6.bmp deleted file mode 100644 index ebaeca9..0000000 Binary files a/assets/images/tetris_main_back_level6.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level6.jpg b/assets/images/tetris_main_back_level6.jpg deleted file mode 100644 index 559898d..0000000 Binary files a/assets/images/tetris_main_back_level6.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level7.bmp b/assets/images/tetris_main_back_level7.bmp deleted file mode 100644 index f92d223..0000000 Binary files a/assets/images/tetris_main_back_level7.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level7.jpg b/assets/images/tetris_main_back_level7.jpg deleted file mode 100644 index d010cbc..0000000 Binary files a/assets/images/tetris_main_back_level7.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level8.bmp b/assets/images/tetris_main_back_level8.bmp deleted file mode 100644 index 8f392c1..0000000 Binary files a/assets/images/tetris_main_back_level8.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level8.jpg b/assets/images/tetris_main_back_level8.jpg deleted file mode 100644 index 382468a..0000000 Binary files a/assets/images/tetris_main_back_level8.jpg and /dev/null differ diff --git a/assets/images/tetris_main_back_level9.bmp b/assets/images/tetris_main_back_level9.bmp deleted file mode 100644 index 2962513..0000000 Binary files a/assets/images/tetris_main_back_level9.bmp and /dev/null differ diff --git a/assets/images/tetris_main_back_level9.jpg b/assets/images/tetris_main_back_level9.jpg deleted file mode 100644 index 955c1c5..0000000 Binary files a/assets/images/tetris_main_back_level9.jpg and /dev/null differ diff --git a/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/Every Block You Take.mp3 b/assets/music/Every Block You Take.mp3 new file mode 100644 index 0000000..d8534b5 Binary files /dev/null and b/assets/music/Every Block You Take.mp3 differ diff --git a/assets/music/Every Block You Take.ogg b/assets/music/Every Block You Take.ogg new file mode 100644 index 0000000..f3857b1 Binary files /dev/null and b/assets/music/Every Block You Take.ogg 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/amazing.ogg b/assets/music/amazing.ogg new file mode 100644 index 0000000..fe54410 Binary files /dev/null and b/assets/music/amazing.ogg 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/assets/music/boom_tetris.ogg b/assets/music/boom_tetris.ogg new file mode 100644 index 0000000..00e9c3d Binary files /dev/null and b/assets/music/boom_tetris.ogg differ diff --git a/assets/music/great_move.ogg b/assets/music/great_move.ogg new file mode 100644 index 0000000..4045c7e Binary files /dev/null and b/assets/music/great_move.ogg differ diff --git a/assets/music/hard_drop_001.mp3 b/assets/music/hard_drop_001.mp3 new file mode 100644 index 0000000..7b6f1fa Binary files /dev/null and b/assets/music/hard_drop_001.mp3 differ diff --git a/assets/music/hard_drop_001.ogg b/assets/music/hard_drop_001.ogg new file mode 100644 index 0000000..81d7f32 Binary files /dev/null and b/assets/music/hard_drop_001.ogg differ diff --git a/assets/music/impressive.ogg b/assets/music/impressive.ogg new file mode 100644 index 0000000..e0eecf9 Binary files /dev/null and b/assets/music/impressive.ogg differ diff --git a/assets/music/keep_that_ryhtm.ogg b/assets/music/keep_that_ryhtm.ogg new file mode 100644 index 0000000..562c2f3 Binary files /dev/null and b/assets/music/keep_that_ryhtm.ogg differ diff --git a/assets/music/lets_go.ogg b/assets/music/lets_go.ogg new file mode 100644 index 0000000..caab19a Binary files /dev/null and b/assets/music/lets_go.ogg differ diff --git a/assets/music/new_level.mp3 b/assets/music/new_level.mp3 new file mode 100644 index 0000000..d8d4522 Binary files /dev/null and b/assets/music/new_level.mp3 differ diff --git a/assets/music/new_level.ogg b/assets/music/new_level.ogg new file mode 100644 index 0000000..116b95d Binary files /dev/null and b/assets/music/new_level.ogg differ diff --git a/assets/music/nice_combo.ogg b/assets/music/nice_combo.ogg new file mode 100644 index 0000000..3b8c400 Binary files /dev/null and b/assets/music/nice_combo.ogg differ diff --git a/assets/music/nice_combo.wav b/assets/music/nice_combo.wav index 8f7e99c..2210784 100644 Binary files a/assets/music/nice_combo.wav and b/assets/music/nice_combo.wav differ diff --git a/assets/music/smooth_clear.ogg b/assets/music/smooth_clear.ogg new file mode 100644 index 0000000..87bf3d7 Binary files /dev/null and b/assets/music/smooth_clear.ogg differ diff --git a/assets/music/triple_strike.ogg b/assets/music/triple_strike.ogg new file mode 100644 index 0000000..f96843c Binary files /dev/null and b/assets/music/triple_strike.ogg differ diff --git a/assets/music/well_played.ogg b/assets/music/well_played.ogg new file mode 100644 index 0000000..5e199e0 Binary files /dev/null and b/assets/music/well_played.ogg differ diff --git a/assets/music/wonderful.ogg b/assets/music/wonderful.ogg new file mode 100644 index 0000000..1bfee75 Binary files /dev/null and b/assets/music/wonderful.ogg differ diff --git a/assets/music/you_fire.ogg b/assets/music/you_fire.ogg new file mode 100644 index 0000000..3fabd06 Binary files /dev/null and b/assets/music/you_fire.ogg differ diff --git a/assets/music/you_re_unstoppable.ogg b/assets/music/you_re_unstoppable.ogg new file mode 100644 index 0000000..ed0fdf8 Binary files /dev/null and b/assets/music/you_re_unstoppable.ogg differ diff --git a/assets/videos/spacetris_intro.mp4 b/assets/videos/spacetris_intro.mp4 new file mode 100644 index 0000000..f53718f Binary files /dev/null and b/assets/videos/spacetris_intro.mp4 differ diff --git a/build-debug-and-run.bat b/build-debug-and-run.bat new file mode 100644 index 0000000..2444e97 --- /dev/null +++ b/build-debug-and-run.bat @@ -0,0 +1,11 @@ +@echo off +REM Build and run debug executable for the Spacetris project +SETLOCAL +cd /d "%~dp0" +cmake --build build-msvc --config Debug +if errorlevel 1 ( + echo Build failed. + exit /b %ERRORLEVEL% +) +"%~dp0build-msvc\Debug\spacetris.exe" +ENDLOCAL diff --git a/build-debug-and-run.ps1 b/build-debug-and-run.ps1 new file mode 100644 index 0000000..05f536a --- /dev/null +++ b/build-debug-and-run.ps1 @@ -0,0 +1,162 @@ +param( + [switch]$NoRun +) + +# Ensure script runs from repo root (where this script lives) +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $root + +Write-Host "Working directory: $PWD" + +# Require Visual Studio 18 (2026) +function Find-VisualStudio { + $vswherePaths = @() + if ($env:ProgramFiles -ne $null) { $vswherePaths += Join-Path $env:ProgramFiles 'Microsoft Visual Studio\Installer\vswhere.exe' } + if (${env:ProgramFiles(x86)} -ne $null) { $vswherePaths += Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' } + + foreach ($p in $vswherePaths) { + if (Test-Path $p) { return @{Tool=$p} } + } + + return $null +} + +function Get-VS18 { + $fv = Find-VisualStudio + $vswhere = $null + if ($fv -and $fv.Tool) { $vswhere = $fv.Tool } + if ($vswhere) { + try { + $inst18 = & $vswhere -version "[18.0,19.0)" -products * -requires Microsoft.Component.MSBuild -property installationPath 2>$null + if ($inst18) { + $candidate = 'Visual Studio 18 2026' + $has = & cmake --help | Select-String -Pattern $candidate -SimpleMatch -Quiet + if ($has) { + $inst18Path = $inst18.Trim() + $vcvars = Join-Path $inst18Path 'VC\\Auxiliary\\Build\\vcvarsall.bat' + if (Test-Path $vcvars) { return @{Generator=$candidate; InstallPath=$inst18Path} } + Write-Error "Visual Studio 18 detected at $inst18Path but C++ toolchain (vcvarsall.bat) is missing. Install the Desktop development with C++ workload." + } + } + } catch { + # fall through to path checks below + } + } + + # fallback: check common install locations (allow for non-standard drive) + $commonPaths = @( + 'D:\Program Files\Microsoft Visual Studio\2026\Community', + 'D:\Program Files\Microsoft Visual Studio\2026\Professional', + 'D:\Program Files\Microsoft Visual Studio\2026\Enterprise', + 'C:\Program Files\Microsoft Visual Studio\2026\Community', + 'C:\Program Files\Microsoft Visual Studio\2026\Professional', + 'C:\Program Files\Microsoft Visual Studio\2026\Enterprise' + ) + foreach ($p in $commonPaths) { + if (Test-Path $p) { + $vcvars = Join-Path $p 'VC\\Auxiliary\\Build\\vcvarsall.bat' + if (Test-Path $vcvars) { return @{Generator='Visual Studio 18 2026'; InstallPath=$p} } + } + } + + return $null +} + +# Resolve vcpkg root/toolchain (prefer env, then C:\vcpkg, then repo-local) +$vcpkgRoot = $env:VCPKG_ROOT +if (-not $vcpkgRoot -or -not (Test-Path $vcpkgRoot)) { + if (Test-Path 'C:\vcpkg') { + $vcpkgRoot = 'C:\vcpkg' + $env:VCPKG_ROOT = $vcpkgRoot + } elseif (Test-Path (Join-Path $root 'vcpkg')) { + $vcpkgRoot = Join-Path $root 'vcpkg' + $env:VCPKG_ROOT = $vcpkgRoot + } +} + +$toolchainFile = $null +if ($vcpkgRoot) { + $candidateToolchain = Join-Path $vcpkgRoot 'scripts\buildsystems\vcpkg.cmake' + if (Test-Path $candidateToolchain) { $toolchainFile = $candidateToolchain } +} + +# Determine VS18 generator and reconfigure build-msvc if needed +$preferred = Get-VS18 +if ($preferred) { + Write-Host "Detected Visual Studio: $($preferred.Generator) at $($preferred.InstallPath)" +} else { + Write-Error "Visual Studio 18 (2026) with Desktop development with C++ workload not found. Install VS 2026 and retry." + exit 1 +} + +if (-not $toolchainFile) { + Write-Error "vcpkg toolchain not found. Set VCPKG_ROOT or install vcpkg (expected scripts/buildsystems/vcpkg.cmake)." + exit 1 +} + +# If build-msvc exists, check CMakeCache generator +$cacheFile = Join-Path $root 'build-msvc\CMakeCache.txt' +$reconfigured = $false +if (-not (Test-Path $cacheFile)) { + Write-Host "Configuring build-msvc with generator $($preferred.Generator) and vcpkg toolchain $toolchainFile" + & cmake -S . -B build-msvc -G "$($preferred.Generator)" -A x64 -DCMAKE_TOOLCHAIN_FILE="$toolchainFile" -DVCPKG_TARGET_TRIPLET=x64-windows + $cfgExit = $LASTEXITCODE + if ($cfgExit -ne 0) { Write-Error "CMake configure failed with exit code $cfgExit"; exit $cfgExit } + $reconfigured = $true +} else { + $genLine = Get-Content $cacheFile | Select-String -Pattern 'CMAKE_GENERATOR:INTERNAL=' -SimpleMatch + $toolLine = Get-Content $cacheFile | Select-String -Pattern 'CMAKE_TOOLCHAIN_FILE:FILEPATH=' -SimpleMatch + $currentGen = $null + $currentTool = $null + if ($genLine) { $currentGen = $genLine -replace '.*CMAKE_GENERATOR:INTERNAL=(.*)','$1' } + if ($toolLine) { $currentTool = $toolLine -replace '.*CMAKE_TOOLCHAIN_FILE:FILEPATH=(.*)','$1' } + $needsReconfigure = $false + if ($currentGen -ne $preferred.Generator) { $needsReconfigure = $true } + if (-not $currentTool -or ($currentTool -ne $toolchainFile)) { $needsReconfigure = $true } + + if ($needsReconfigure) { + Write-Host "Generator or toolchain changed; cleaning build-msvc directory for fresh configure." + Remove-Item -Path (Join-Path $root 'build-msvc') -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "Configuring build-msvc with generator $($preferred.Generator) and toolchain $toolchainFile" + & cmake -S . -B build-msvc -G "$($preferred.Generator)" -A x64 -DCMAKE_TOOLCHAIN_FILE="$toolchainFile" -DVCPKG_TARGET_TRIPLET=x64-windows + $cfgExit = $LASTEXITCODE + if ($cfgExit -ne 0) { Write-Error "CMake configure failed with exit code $cfgExit"; exit $cfgExit } + $reconfigured = $true + } else { + Write-Host "CMake cache matches required generator and toolchain." + } +} + +# Build Debug configuration +Write-Host "Running: cmake --build build-msvc --config Debug" +& cmake --build build-msvc --config Debug +$buildExit = $LASTEXITCODE +if ($buildExit -ne 0) { + Write-Error "Build failed with exit code $buildExit" + $vcpkgLog = Join-Path $root 'build-msvc\vcpkg-manifest-install.log' + if (Test-Path $vcpkgLog) { + $log = Get-Content $vcpkgLog -Raw + if ($log -match 'Unable to find a valid Visual Studio instance') { + Write-Error "vcpkg could not locate a valid Visual Studio 18 toolchain. Install VS 2026 with Desktop development with C++ workload and retry." + } + } + + exit $buildExit +} + +if ($NoRun) { + Write-Host "Build succeeded; skipping run because -NoRun was specified." + exit 0 +} + +$exePath = Join-Path $root "build-msvc\Debug\spacetris.exe" +if (-not (Test-Path $exePath)) { + Write-Error "Executable not found: $exePath" + exit 1 +} + +Write-Host "Launching: $exePath" +# Launch the executable and wait for it to exit so the caller sees its output. +& $exePath + +exit $LASTEXITCODE diff --git a/build-production-mac.sh b/build-production-mac.sh new file mode 100644 index 0000000..18191db --- /dev/null +++ b/build-production-mac.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +set -euo pipefail + +# macOS Production Build Script for the SDL3 Spacetris project +# Mirrors the Windows PowerShell workflow but uses common POSIX tooling so it +# can be executed on macOS runners or local developer machines. + +PROJECT_NAME="spacetris" +BUILD_DIR="build-release" +OUTPUT_DIR="dist" +PACKAGE_DIR="" +VERSION="$(date +"%Y.%m.%d")" +CLEAN=0 +PACKAGE_ONLY=0 +PACKAGE_RUNTIME_DIR="" +APP_ICON_SRC="assets/favicon/favicon-512x512.png" +APP_ICON_ICNS="assets/favicon/AppIcon.icns" + +print_usage() { + cat <<'USAGE' +Usage: ./build-production-mac.sh [options] + +Options: + -c, --clean Remove existing build + dist folders before running + -p, --package-only Skip compilation and only rebuild the distributable + -o, --output DIR Customize output directory (default: dist) + -h, --help Show this help text +USAGE +} + +log() { + local level=$1; shift + case "$level" in + INFO) printf '\033[36m[INFO]\033[0m %s\n' "$*" ;; + OK) printf '\033[32m[ OK ]\033[0m %s\n' "$*" ;; + WARN) printf '\033[33m[WARN]\033[0m %s\n' "$*" ;; + ERR) printf '\033[31m[ERR ]\033[0m %s\n' "$*" ;; + *) printf '%s\n' "$*" ;; + esac +} + +require_macos() { + if [[ $(uname) != "Darwin" ]]; then + log ERR "This script is intended for macOS hosts." + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -c|--clean) CLEAN=1; shift ;; + -p|--package-only) PACKAGE_ONLY=1; shift ;; + -o|--output) + if [[ $# -lt 2 ]]; then + log ERR "--output requires a directory argument" + exit 1 + fi + OUTPUT_DIR="$2"; shift 2 ;; + -h|--help) print_usage; exit 0 ;; + *) + log ERR "Unknown argument: $1" + print_usage + exit 1 ;; + esac + done +} + +configure_paths() { + PACKAGE_DIR="${OUTPUT_DIR}/SpacetrisGame-mac" +} + +generate_icns_if_needed() { + if [[ -f "$APP_ICON_ICNS" ]]; then + return + fi + if [[ ! -f "$APP_ICON_SRC" ]]; then + log WARN "Icon source PNG not found ($APP_ICON_SRC); skipping .icns generation" + return + fi + if ! command -v iconutil >/dev/null 2>&1; then + log WARN "iconutil not available; skipping .icns generation" + return + fi + log INFO "Generating AppIcon.icns from $APP_ICON_SRC ..." + tmpdir=$(mktemp -d) + iconset="$tmpdir/AppIcon.iconset" + mkdir -p "$iconset" + # Generate required sizes from 512 base; sips will downscale + for size in 16 32 64 128 256 512; do + sips -s format png "$APP_ICON_SRC" --resampleHeightWidth $size $size --out "$iconset/icon_${size}x${size}.png" >/dev/null + sips -s format png "$APP_ICON_SRC" --resampleHeightWidth $((size*2)) $((size*2)) --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$iconset" -o "$APP_ICON_ICNS" || log WARN "iconutil failed to create .icns" + rm -rf "$tmpdir" + if [[ -f "$APP_ICON_ICNS" ]]; then + log OK "Created $APP_ICON_ICNS" + fi +} + +clean_previous() { + if (( CLEAN )); then + log INFO "Cleaning previous build artifacts..." + rm -rf "$BUILD_DIR" "$OUTPUT_DIR" + log OK "Previous artifacts removed" + fi +} + +configure_and_build() { + if (( PACKAGE_ONLY )); then + return + fi + + log INFO "Configuring CMake (Release)..." + cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + log OK "CMake configure complete" + + log INFO "Building Release target..." + cmake --build "$BUILD_DIR" --config Release + log OK "Build finished" +} + +resolve_executable() { + local candidates=( + "$BUILD_DIR/Release/${PROJECT_NAME}" + "$BUILD_DIR/${PROJECT_NAME}" + "$BUILD_DIR/${PROJECT_NAME}.app/Contents/MacOS/${PROJECT_NAME}" + ) + + for path in "${candidates[@]}"; do + if [[ -x "$path" ]]; then + EXECUTABLE_PATH="$path" + break + fi + done + + if [[ -z ${EXECUTABLE_PATH:-} ]]; then + log ERR "Unable to locate built executable." + exit 1 + fi + + if [[ "$EXECUTABLE_PATH" == *.app/Contents/MacOS/* ]]; then + APP_BUNDLE_PATH="${EXECUTABLE_PATH%/Contents/MacOS/*}" + else + APP_BUNDLE_PATH="" + fi + + log OK "Using executable: $EXECUTABLE_PATH" +} + +prepare_package_dir() { + log INFO "Creating package directory $PACKAGE_DIR ..." + rm -rf "$PACKAGE_DIR" + mkdir -p "$PACKAGE_DIR" +} + +copy_binary_or_bundle() { + if [[ -n ${APP_BUNDLE_PATH:-} ]]; then + log INFO "Copying app bundle..." + rsync -a "$APP_BUNDLE_PATH" "$PACKAGE_DIR/" + local app_name="${APP_BUNDLE_PATH##*/}" + PACKAGE_BINARY="${app_name}/Contents/MacOS/${PROJECT_NAME}" + PACKAGE_RUNTIME_DIR="$PACKAGE_DIR/${app_name}/Contents/MacOS" + else + log INFO "Copying executable..." + cp "$EXECUTABLE_PATH" "$PACKAGE_DIR/${PROJECT_NAME}" + chmod +x "$PACKAGE_DIR/${PROJECT_NAME}" + PACKAGE_BINARY="${PROJECT_NAME}" + PACKAGE_RUNTIME_DIR="$PACKAGE_DIR" + fi + log OK "Binary ready (${PACKAGE_BINARY})" +} + +copy_assets() { + if [[ -n ${APP_BUNDLE_PATH:-} ]]; then + log INFO "Assets already bundled inside the .app; skipping external copy." + return + fi + log INFO "Copying assets..." + local folders=("assets" "fonts") + for folder in "${folders[@]}"; do + if [[ -d "$folder" ]]; then + rsync -a "$folder" "$PACKAGE_DIR/" + log OK "Copied $folder" + fi + done + if [[ -f "FreeSans.ttf" ]]; then + cp "FreeSans.ttf" "$PACKAGE_DIR/" + log OK "Copied FreeSans.ttf" + fi +} + +copy_dependencies() { + log INFO "Collecting dynamic libraries from vcpkg..." + local triplets=("arm64-osx" "x64-osx" "universal-osx") + local bases=("$BUILD_DIR/vcpkg_installed" "vcpkg_installed") + local copied_names=() + + for base in "${bases[@]}"; do + for triplet in "${triplets[@]}"; do + for sub in lib bin; do + local dir="$base/$triplet/$sub" + if [[ -d "$dir" ]]; then + while IFS= read -r -d '' dylib; do + local name=$(basename "$dylib") + local seen=0 + for existing in "${copied_names[@]}"; do + if [[ "$existing" == "$name" ]]; then + seen=1 + break + fi + done + if (( !seen )); then + cp "$dylib" "$PACKAGE_RUNTIME_DIR/" + copied_names+=("$name") + log OK "Copied $name" + fi + done < <(find "$dir" -maxdepth 1 -type f -name '*.dylib' -print0) + fi + done + done + done + + if [[ ${#copied_names[@]} -eq 0 ]]; then + log WARN "No .dylib files found; ensure vcpkg installed macOS triplet dependencies." + fi +} + +ensure_rpath() { + local binary="$PACKAGE_DIR/$PACKAGE_BINARY" + if [[ ! -f "$binary" ]]; then + return + fi + if command -v otool >/dev/null 2>&1 && command -v install_name_tool >/dev/null 2>&1; then + if ! otool -l "$binary" | grep -A2 LC_RPATH | grep -q '@executable_path'; then + log INFO "Adding @executable_path rpath" + install_name_tool -add_rpath "@executable_path" "$binary" + fi + fi +} + +create_launchers() { + local launch_command="./${PROJECT_NAME}" + if [[ "$PACKAGE_BINARY" == *.app/Contents/MacOS/* ]]; then + local app_dir="${PACKAGE_BINARY%%/Contents/*}" + launch_command="open \"./${app_dir}\"" + fi + + cat > "$PACKAGE_DIR/Launch-Spacetris.command" < "$PACKAGE_DIR/README-mac.txt" < 0 )); then + log WARN "Missing: ${missing[*]}" + else + log OK "Package looks complete" + fi +} + +create_zip() { + mkdir -p "$OUTPUT_DIR" + local zip_name="SpacetrisGame-mac-${VERSION}.zip" + local zip_path="$OUTPUT_DIR/$zip_name" + log INFO "Creating zip archive $zip_path ..." + if command -v ditto >/dev/null 2>&1; then + ditto -c -k --keepParent "$PACKAGE_DIR" "$zip_path" + else + (cd "$OUTPUT_DIR" && zip -r "$zip_name" "$(basename "$PACKAGE_DIR")") + fi + log OK "Zip created" +} + +main() { + parse_args "$@" + require_macos + configure_paths + + if [[ ! -f "CMakeLists.txt" ]]; then + log ERR "Run from repository root (CMakeLists.txt missing)." + exit 1 + fi + + log INFO "======================================" + log INFO " macOS Production Builder" + log INFO "======================================" + log INFO "Version: $VERSION" + log INFO "Output: $OUTPUT_DIR" + + clean_previous + generate_icns_if_needed + configure_and_build + resolve_executable + prepare_package_dir + copy_binary_or_bundle + copy_assets + copy_dependencies + ensure_rpath + create_launchers + validate_package + create_zip + create_dmg + + log INFO "Done. Package available at $PACKAGE_DIR" +} + +create_dmg() { + if [[ -z ${APP_BUNDLE_PATH:-} ]]; then + log INFO "No app bundle detected; skipping DMG creation" + return + fi + + local app_name="${APP_BUNDLE_PATH##*/}" + local dmg_name="SpacetrisGame-mac-${VERSION}.dmg" + local dmg_path="$OUTPUT_DIR/$dmg_name" + + if [[ ! -f "scripts/create-dmg.sh" ]]; then + log WARN "scripts/create-dmg.sh not found; skipping DMG creation" + return + fi + + log INFO "Creating DMG installer: $dmg_path" + bash scripts/create-dmg.sh "$PACKAGE_DIR/$app_name" "$dmg_path" || log WARN "DMG creation failed" + + if [[ -f "$dmg_path" ]]; then + log OK "DMG created: $dmg_path" + fi +} + +main "$@" diff --git a/build-production.bat b/build-production.bat index 6d6dd6f..7d49444 100644 --- a/build-production.bat +++ b/build-production.bat @@ -1,11 +1,11 @@ @echo off -REM Simple Production Build Script for Tetris SDL3 +REM Simple Production Build Script for Spacetris SDL3 REM This batch file builds and packages the game for distribution setlocal EnableDelayedExpansion echo ====================================== -echo Tetris SDL3 Production Builder +echo Spacetris SDL3 Production Builder echo ====================================== echo. @@ -45,13 +45,13 @@ cd .. REM Create distribution directory echo Creating distribution package... -mkdir "dist\TetrisGame" +mkdir "dist\SpacetrisGame" REM Copy executable -if exist "build-release\Release\tetris.exe" ( - copy "build-release\Release\tetris.exe" "dist\TetrisGame\" -) else if exist "build-release\tetris.exe" ( - copy "build-release\tetris.exe" "dist\TetrisGame\" +if exist "build-release\Release\spacetris.exe" ( + copy "build-release\Release\spacetris.exe" "dist\SpacetrisGame\" +) else if exist "build-release\spacetris.exe" ( + copy "build-release\spacetris.exe" "dist\SpacetrisGame\" ) else ( echo Error: Executable not found! pause @@ -60,27 +60,52 @@ if exist "build-release\Release\tetris.exe" ( REM Copy assets echo Copying game assets... -if exist "assets" xcopy "assets" "dist\TetrisGame\assets\" /E /I /Y -if exist "FreeSans.ttf" copy "FreeSans.ttf" "dist\TetrisGame\" +if exist "assets" xcopy "assets" "dist\SpacetrisGame\assets\" /E /I /Y +if exist "FreeSans.ttf" copy "FreeSans.ttf" "dist\SpacetrisGame\" REM Copy SDL DLLs (if available) - SDL_image no longer needed echo Copying dependencies... -if exist "vcpkg_installed\x64-windows\bin\SDL3.dll" copy "vcpkg_installed\x64-windows\bin\SDL3.dll" "dist\TetrisGame\" -if exist "vcpkg_installed\x64-windows\bin\SDL3_ttf.dll" copy "vcpkg_installed\x64-windows\bin\SDL3_ttf.dll" "dist\TetrisGame\" +set "PackageDir=dist\SpacetrisGame" +set "copiedDependencies=0" + +call :CopyDependencyDir "build-release\vcpkg_installed\x64-windows\bin" +call :CopyDependencyDir "vcpkg_installed\x64-windows\bin" + +if "%copiedDependencies%"=="0" ( + echo Warning: No dependency DLLs were copied. Ensure vcpkg release binaries exist. +) REM Create launcher batch file -echo @echo off > "dist\TetrisGame\Launch-Tetris.bat" -echo cd /d "%%~dp0" >> "dist\TetrisGame\Launch-Tetris.bat" -echo tetris.exe >> "dist\TetrisGame\Launch-Tetris.bat" -echo pause >> "dist\TetrisGame\Launch-Tetris.bat" +echo @echo off > "dist\SpacetrisGame\Launch-Spacetris.bat" +echo cd /d "%%~dp0" >> "dist\SpacetrisGame\Launch-Spacetris.bat" +echo spacetris.exe >> "dist\SpacetrisGame\Launch-Spacetris.bat" +echo pause >> "dist\SpacetrisGame\Launch-Spacetris.bat" echo. echo ====================================== echo Build Completed Successfully! echo ====================================== -echo Package location: dist\TetrisGame +echo Package location: dist\SpacetrisGame echo. echo The game is ready for distribution! -echo Users can run tetris.exe or Launch-Tetris.bat +echo Users can run spacetris.exe or Launch-Spacetris.bat echo. pause +goto :eof + +:CopyDependencyDir +set "dllDir=%~1" +if not exist "%dllDir%" goto :eof +echo Scanning %dllDir% for DLLs... +for %%F in ("%dllDir%\*.dll") do ( + if exist "%%~fF" ( + if exist "%PackageDir%\%%~nxF" ( + copy /Y "%%~fF" "%PackageDir%\%%~nxF" >nul + ) else ( + copy "%%~fF" "%PackageDir%\" >nul + ) + echo Copied %%~nxF + set "copiedDependencies=1" + ) +) +goto :eof diff --git a/build-production.ps1 b/build-production.ps1 index 7be74ea..53a672b 100644 --- a/build-production.ps1 +++ b/build-production.ps1 @@ -1,10 +1,10 @@ #!/usr/bin/env pwsh <# .SYNOPSIS -Production Build Script for Tetris SDL3 Game +Production Build Script for Spacetris SDL3 Game .DESCRIPTION -This script builds the Tetris game for production distribution, including: +This script builds the Spacetris game for production distribution, including: - Clean Release build with optimizations - Dependency collection and packaging - Asset organization and validation @@ -31,9 +31,9 @@ param( ) # Configuration -$ProjectName = "tetris" +$ProjectName = "spacetris" $BuildDir = "build-release" -$PackageDir = Join-Path $OutputDir "TetrisGame" +$PackageDir = Join-Path $OutputDir "SpacetrisGame" $Version = Get-Date -Format "yyyy.MM.dd" # Colors for output @@ -52,7 +52,7 @@ function Write-Warning { Write-ColorOutput Yellow $args } function Write-Error { Write-ColorOutput Red $args } Write-Info "======================================" -Write-Info " Tetris SDL3 Production Builder" +Write-Info " Spacetris SDL3 Production Builder" Write-Info "======================================" Write-Info "Version: $Version" Write-Info "Output: $PackageDir" @@ -155,32 +155,28 @@ foreach ($font in $FontFiles) { } # Step 7: Copy dependencies (DLLs) -Write-Info "Copying SDL3 dependencies..." -$VcpkgInstalled = Join-Path "vcpkg_installed" "x64-windows" -if (Test-Path $VcpkgInstalled) { - $DllPaths = @( - Join-Path $VcpkgInstalled "bin\SDL3.dll", - Join-Path $VcpkgInstalled "bin\SDL3_ttf.dll" - ) - - $CopiedDlls = 0 - foreach ($dll in $DllPaths) { - if (Test-Path $dll) { - Copy-Item $dll $PackageDir - $dllName = Split-Path $dll -Leaf - Write-Success "Copied $dllName" - $CopiedDlls++ - } else { - Write-Warning "Warning: $dll not found" +Write-Info "Copying runtime dependencies..." +$buildVcpkgBin = Join-Path (Join-Path $BuildDir "vcpkg_installed") "x64-windows/bin" +$repoVcpkgBin = "vcpkg_installed/x64-windows/bin" +$DependencyDirs = @($buildVcpkgBin, $repoVcpkgBin) | Where-Object { $_ } | Select-Object -Unique + +$copiedNames = @{} +foreach ($dir in $DependencyDirs) { + if (!(Test-Path $dir)) { continue } + Write-Info "Scanning $dir for DLLs..." + $dlls = Get-ChildItem -Path $dir -Filter "*.dll" -ErrorAction SilentlyContinue + foreach ($dll in $dlls) { + $dest = Join-Path $PackageDir $dll.Name + Copy-Item $dll.FullName $dest -Force + if (-not $copiedNames.ContainsKey($dll.Name)) { + Write-Success "Copied $($dll.Name)" + $copiedNames[$dll.Name] = $true } } - - if ($CopiedDlls -eq 0) { - Write-Warning "No SDL DLLs found in vcpkg installation" - Write-Warning "You may need to manually copy SDL3 DLLs to the package" - } -} else { - Write-Warning "vcpkg installation not found. SDL DLLs must be manually copied." +} + +if ($copiedNames.Count -eq 0) { + Write-Warning "No dependency DLLs were copied. Please ensure vcpkg has been built for Release." } # Step 8: Create README and batch file for easy launching @@ -188,7 +184,7 @@ Write-Info "Creating distribution files..." # Create README $ReadmeContent = @" -Tetris SDL3 Game - Release $Version +Spacetris SDL3 Game - Release $Version ===================================== ## System Requirements @@ -198,7 +194,7 @@ Tetris SDL3 Game - Release $Version ## Installation 1. Extract all files to a folder -2. Run tetris.exe +2. Run spacetris.exe ## Controls - Arrow Keys: Move pieces @@ -210,17 +206,17 @@ Tetris SDL3 Game - Release $Version - Esc: Return to menu ## Troubleshooting -- If the game doesn't start, ensure all DLL files are in the same folder as tetris.exe +- If the game doesn't start, ensure all DLL files are in the same folder as spacetris.exe - For audio issues, check that your audio drivers are up to date - The game requires the assets folder to be in the same directory as the executable ## Files Included -- tetris.exe - Main game executable -- SDL3.dll, SDL3_ttf.dll - Required libraries +- spacetris.exe - Main game executable +- SDL3.dll, SDL3_ttf.dll, SDL3_image.dll - Required libraries - assets/ - Game assets (images, music, fonts) - FreeSans.ttf - Main font file -Enjoy playing Tetris! +Enjoy playing Spacetris! "@ $ReadmeContent | Out-File -FilePath (Join-Path $PackageDir "README.txt") -Encoding UTF8 @@ -234,8 +230,8 @@ tetris.exe pause "@ -$BatchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Tetris.bat") -Encoding ASCII -Write-Success "Created Launch-Tetris.bat" +$BatchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Spacetris.bat") -Encoding ASCII +Write-Success "Created Launch-Spacetris.bat" # Step 9: Validate package Write-Info "🔍 Validating package..." @@ -263,7 +259,7 @@ $PackageSize = (Get-ChildItem $PackageDir -Recurse | Measure-Object -Property Le $PackageSizeMB = [math]::Round($PackageSize / 1MB, 2) # Step 11: Create ZIP archive (optional) -$ZipPath = Join-Path $OutputDir "TetrisGame-$Version.zip" +$ZipPath = Join-Path $OutputDir "SpacetrisGame-$Version.zip" Write-Info "📁 Creating ZIP archive..." try { Compress-Archive -Path $PackageDir -DestinationPath $ZipPath -Force @@ -284,5 +280,5 @@ if (Test-Path $ZipPath) { } Write-Info "" Write-Info "The game is ready for distribution!" -Write-Info "Users can run tetris.exe or Launch-Tetris.bat" +Write-Info "Users can run spacetris.exe or Launch-Spacetris.bat" Write-Info "" diff --git a/challenge_mode.md b/challenge_mode.md new file mode 100644 index 0000000..22f6b0b --- /dev/null +++ b/challenge_mode.md @@ -0,0 +1,287 @@ +# Spacetris — Challenge Mode (Asteroids) Implementation Spec for VS Code AI Agent + +> Goal: Implement/extend **CHALLENGE** gameplay in Spacetris (not a separate mode), based on 100 levels with **asteroid** prefilled blocks that must be destroyed to advance. + +--- + +## 1) High-level Requirements + +### Modes +- Existing mode remains **ENDLESS**. +- Add/extend **CHALLENGE** mode with **100 levels**. + +### Core Challenge Loop +- Each level starts with **prefilled obstacle blocks** called **Asteroids**. +- **Level N** starts with **N asteroids** (placed increasingly higher as level increases). +- Player advances to the next level when **ALL asteroids are destroyed**. +- Gravity (and optionally lock pressure) increases per level. + +### Asteroid concept +Asteroids are special blocks placed into the grid at level start: +- They are **not** player-controlled pieces. +- They have **types** and **hit points** (how many times they must be cleared via line clears). + +--- + +## 2) Asteroid Types & Rules + +Define asteroid types and their behavior: + +### A) Normal Asteroid +- `hitsRemaining = 1` +- Removed when its row is cleared once. +- Never moves (no gravity). + +### B) Armored Asteroid +- `hitsRemaining = 2` +- On first line clear that includes it: decrement hits and change to cracked visual state. +- On second clear: removed. +- Never moves (no gravity). + +### C) Falling Asteroid +- `hitsRemaining = 2` +- On first clear: decrement hits, then **becomes gravity-enabled** (drops until resting). +- On second clear: removed. + +### D) Core Asteroid (late levels) +- `hitsRemaining = 3` +- On each clear: decrement hits and change visual state. +- After first hit (or after any hit — choose consistent rule) it becomes gravity-enabled. +- On final clear: removed (optionally trigger bigger VFX). + +**Important:** These are all within the same CHALLENGE mode. + +--- + +## 3) Level Progression Rules (100 Levels) + +### Asteroid Count +- `asteroidsToPlace = level` (Level 1 -> 1 asteroid, Level 2 -> 2 asteroids, …) +- Recommendation for implementation safety: + - If `level` becomes too large to place comfortably, still place `level` but distribute across more rows and allow overlaps only if empty. + - If needed, implement a soft cap for placement attempts (avoid infinite loops). If cannot place all, place as many as possible and log/telemetry. + +### Placement Height / Region +- Early levels: place in bottom 2–4 rows. +- Mid levels: bottom 6–10 rows. +- Late levels: up to ~half board height. +- Use a function to define a `minRow..maxRow` region based on `level`. + +Example guidance: +- `maxRow = boardHeight - 1` +- `minRow = boardHeight - 1 - clamp(2 + level/3, 2, boardHeight/2)` + +### Type Distribution by Level (suggested) +- Levels 1–9: Normal only +- Levels 10–19: add Armored (small %) +- Levels 20–59: add Falling (increasing %) +- Levels 60–100: add Core (increasing %) + +--- + +## 4) Difficulty Scaling + +### Gravity Speed Scaling +Implement per-level gravity scale: +- `gravity = baseGravity * (1.0f + level * 0.02f)` (tune) +- Or use a curve/table. + +Optional additional scaling: +- Reduced lock delay slightly at higher levels +- Slightly faster DAS/ARR (if implemented) + +--- + +## 5) Win/Lose Conditions + +### Level Completion +- Level completes when: `asteroidsRemaining == 0` +- Then: + - Clear board (or keep board — choose one consistent behavior; recommended: **clear board** for clean progression). + - Show short transition (optional). + - Load next level, until level 100. +- After level 100 completion: show completion screen + stats. + +### Game Over +- Standard Spacetris game over: stack reaches spawn/top (existing behavior). + +--- + +## 6) Rendering / UI Requirements + +### Visual Differentiation +Asteroids must be visually distinct from normal tetromino blocks. + +Provide visual states: +- Normal: rock texture +- Armored: plated / darker +- Cracked: visible cracks +- Falling: glow rim / hazard stripes +- Core: pulsing inner core + +Minimum UI additions (Challenge): +- Display `LEVEL: X/100` +- Display `ASTEROIDS REMAINING: N` (or an icon counter) + +--- + +## 7) Data Structures (C++ Guidance) + +### Cell Representation +Each grid cell must store: +- Whether occupied +- If occupied: is it part of normal tetromino or an asteroid +- If asteroid: type + hitsRemaining + gravityEnabled + visualState + +Suggested enums: +```cpp +enum class CellKind { Empty, Tetromino, Asteroid }; + +enum class AsteroidType { Normal, Armored, Falling, Core }; + +struct AsteroidCell { + AsteroidType type; + uint8_t hitsRemaining; + bool gravityEnabled; + uint8_t visualState; // optional (e.g. 0..n) +}; + +struct Cell { + CellKind kind; + // For Tetromino: color/type id + // For Asteroid: AsteroidCell data +}; +```` + +--- + +## 8) Line Clear Processing Rules (Important) + +When a line is cleared: + +1. Detect full rows (existing). +2. For each cleared row: + + * For each cell: + + * If `kind == Asteroid`: + + * `hitsRemaining--` + * If `hitsRemaining == 0`: remove (cell becomes Empty) + * Else: + + * Update its visual state (cracked/damaged) + * If asteroid type is Falling/Core and rule says it becomes gravity-enabled on first hit: + + * `gravityEnabled = true` +3. After clearing rows and collapsing the grid: + + * Apply **asteroid gravity step**: + + * For all gravity-enabled asteroid cells: let them fall until resting. + * Ensure stable iteration (bottom-up scan). +4. Recount asteroids remaining; if 0 -> level complete. + +**Note:** Decide whether gravity-enabled asteroids fall immediately after the first hit (recommended) and whether they fall as individual cells (recommended) or as clusters (optional later). + +--- + +## 9) Asteroid Gravity Algorithm (Simple + Stable) + +Implement a pass: + +* Iterate from bottom-2 to top (bottom-up). +* If cell is gravity-enabled asteroid and below is empty: + + * Move down by one +* Repeat passes until no movement OR do a while-loop per cell to drop fully. + +Be careful to avoid skipping cells when moving: + +* Use bottom-up iteration and drop-to-bottom logic. + +--- + +## 10) Level Generation (Deterministic Option) + +To make challenge reproducible: + +* Use a seed: `seed = baseSeed + level` +* Place asteroids with RNG based on level seed. + +Placement constraints: + +* Avoid placing asteroids in the spawn zone/top rows. +* Avoid creating impossible scenarios too early: + + * For early levels, ensure at least one vertical shaft exists. + +--- + +## 11) Tasks Checklist for AI Agent + +### A) Add Challenge Level System + +* [ ] Add `currentLevel (1..100)` and `mode == CHALLENGE`. +* [ ] Add `StartChallengeLevel(level)` function. +* [ ] Reset/prepare board state for each level (recommended: clear board). + +### B) Asteroid Placement + +* [ ] Implement `PlaceAsteroids(level)`: + + * Determine region of rows + * Choose type distribution + * Place `level` asteroid cells into empty spots + +### C) Line Clear Hook + +* [ ] Modify existing line clear code: + + * Apply asteroid hit logic + * Update visuals + * Enable gravity where required + +### D) Gravity-enabled Asteroids + +* [ ] Implement `ApplyAsteroidGravity()` after line clears and board collapse. + +### E) Level Completion + +* [ ] Track `asteroidsRemaining`. +* [ ] When 0: trigger level transition and `StartChallengeLevel(level+1)`. + +### F) UI + +* [ ] Add level & asteroids remaining display. + +--- + +## 12) Acceptance Criteria + +* Level 1 spawns exactly 1 asteroid. +* Level N spawns N asteroids. +* Destroying asteroids requires: + + * Normal: 1 clear + * Armored: 2 clears + * Falling: 2 clears + becomes gravity-enabled after first hit + * Core: 3 clears (+ gravity-enabled rule) +* Player advances only when all asteroids are destroyed. +* Gravity increases by level and is clearly noticeable by mid-levels. +* No infinite loops in placement or gravity. +* Challenge works end-to-end through level 100. + +--- + +## 13) Notes / Tuning Hooks + +Expose tuning constants: + +* `baseGravity` +* `gravityPerLevel` +* `minAsteroidRow(level)` +* `typeDistribution(level)` weights +* `coreGravityOnHit` rule + +--- \ No newline at end of file diff --git a/cmake/MacBundleInfo.plist.in b/cmake/MacBundleInfo.plist.in new file mode 100644 index 0000000..8720039 --- /dev/null +++ b/cmake/MacBundleInfo.plist.in @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + com.example.spacetris + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Spacetris + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + + diff --git a/cmake/ProductionBuild.cmake b/cmake/ProductionBuild.cmake index d81c519..6c5a0d3 100644 --- a/cmake/ProductionBuild.cmake +++ b/cmake/ProductionBuild.cmake @@ -12,12 +12,19 @@ if(CMAKE_BUILD_TYPE STREQUAL "Release") set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /OPT:REF /OPT:ICF") # Enable whole program optimization - set_target_properties(tetris PROPERTIES + # Detect game target (spacetris only) + if(TARGET spacetris) + set(GAME_TARGET spacetris) + else() + message(FATAL_ERROR "No game target found (expected 'spacetris')") + endif() + + set_target_properties(${GAME_TARGET} PROPERTIES INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE ) - + # Set subsystem to Windows (no console) for release - set_target_properties(tetris PROPERTIES + set_target_properties(${GAME_TARGET} PROPERTIES WIN32_EXECUTABLE TRUE LINK_FLAGS_RELEASE "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup" ) @@ -30,53 +37,77 @@ if(CMAKE_BUILD_TYPE STREQUAL "Release") endif() endif() +# Ensure we have a GAME_TARGET set (spacetris only) +if(NOT DEFINED GAME_TARGET) + if(TARGET spacetris) + set(GAME_TARGET spacetris) + else() + message(FATAL_ERROR "No game target found (expected 'spacetris')") + endif() +endif() + # Custom target for creating distribution package (renamed to avoid conflict with CPack) if(WIN32) # Windows-specific packaging - add_custom_target(dist_package - COMMAND ${CMAKE_COMMAND} -E echo "Creating Windows distribution package..." - COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/TetrisGame" - COMMAND ${CMAKE_COMMAND} -E copy "$" "${CMAKE_BINARY_DIR}/package/TetrisGame/" - COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "${CMAKE_BINARY_DIR}/package/TetrisGame/assets" - COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "${CMAKE_BINARY_DIR}/package/TetrisGame/" - COMMENT "Packaging Tetris for distribution" - DEPENDS tetris - ) + if(GAME_TARGET) + add_custom_target(dist_package + COMMAND ${CMAKE_COMMAND} -E echo "Creating Windows distribution package..." + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/SpacetrisGame" + COMMAND ${CMAKE_COMMAND} -E copy "$" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/assets" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/" + COMMENT "Packaging Spacetris for distribution" + DEPENDS ${GAME_TARGET} + ) + else() + message(WARNING "No game target detected; skipping dist_package target.") + endif() # Try to copy SDL DLLs automatically (SDL_image no longer needed) find_file(SDL3_DLL SDL3.dll PATHS "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin" NO_DEFAULT_PATH) find_file(SDL3_TTF_DLL SDL3_ttf.dll PATHS "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin" NO_DEFAULT_PATH) - - if(SDL3_DLL) - add_custom_command(TARGET dist_package POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_DLL}" "${CMAKE_BINARY_DIR}/package/TetrisGame/" - COMMENT "Copying SDL3.dll" - ) - endif() - - if(SDL3_TTF_DLL) - add_custom_command(TARGET dist_package POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_TTF_DLL}" "${CMAKE_BINARY_DIR}/package/TetrisGame/" - COMMENT "Copying SDL3_ttf.dll" - ) + + if(GAME_TARGET) + if(SDL3_DLL) + add_custom_command(TARGET dist_package POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_DLL}" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/" + COMMENT "Copying SDL3.dll" + ) + endif() + + if(SDL3_TTF_DLL) + add_custom_command(TARGET dist_package POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_TTF_DLL}" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/" + COMMENT "Copying SDL3_ttf.dll" + ) + endif() endif() endif() # Installation rules for system-wide installation -install(TARGETS tetris - RUNTIME DESTINATION bin - COMPONENT Runtime -) +if(GAME_TARGET) + if(APPLE) + install(TARGETS ${GAME_TARGET} + BUNDLE DESTINATION . + COMPONENT Runtime + ) + else() + install(TARGETS ${GAME_TARGET} + RUNTIME DESTINATION bin + COMPONENT Runtime + ) + endif() -install(DIRECTORY assets/ - DESTINATION share/tetris/assets - COMPONENT Runtime -) + install(DIRECTORY assets/ + DESTINATION share/${GAME_TARGET}/assets + COMPONENT Runtime + ) -install(FILES FreeSans.ttf - DESTINATION share/tetris - COMPONENT Runtime -) + install(FILES FreeSans.ttf + DESTINATION share/${GAME_TARGET} + COMPONENT Runtime + ) +endif() # CPack configuration for creating installers (commented out - requires LICENSE file) # set(CPACK_PACKAGE_NAME "Tetris") diff --git a/convert_instructions.bat b/convert_instructions.bat index bf2da84..41a3dea 100644 --- a/convert_instructions.bat +++ b/convert_instructions.bat @@ -1,14 +1,14 @@ @echo off -echo Converting MP3 files to WAV using Windows Media Player... +echo Convert MP3 files to OGG (preferred) for cross-platform playback... echo. REM Check if we have access to Windows Media Format SDK set MUSIC_DIR=assets\music REM List of MP3 files to convert -set FILES=amazing.mp3 boom_tetris.mp3 great_move.mp3 impressive.mp3 keep_that_ryhtm.mp3 lets_go.mp3 nice_combo.mp3 smooth_clear.mp3 triple_strike.mp3 well_played.mp3 wonderful.mp3 you_fire.mp3 you_re_unstoppable.mp3 +set FILES=amazing.mp3 boom_tetris.mp3 great_move.mp3 impressive.mp3 keep_that_ryhtm.mp3 lets_go.mp3 nice_combo.mp3 smooth_clear.mp3 triple_strike.mp3 well_played.mp3 wonderful.mp3 you_fire.mp3 you_re_unstoppable.mp3 hard_drop_001.mp3 new_level.mp3 -echo Please convert these MP3 files to WAV format manually: +echo Please convert these MP3 files to OGG Vorbis manually (or run convert_to_ogg.ps1 on Windows): echo. for %%f in (%FILES%) do ( echo - %MUSIC_DIR%\%%f @@ -17,13 +17,12 @@ for %%f in (%FILES%) do ( echo. echo Recommended settings for conversion: echo - Sample Rate: 44100 Hz -echo - Bit Depth: 16-bit echo - Channels: Stereo (2) -echo - Format: PCM WAV +echo - Use OGG Vorbis quality ~4 (or convert to FLAC if you prefer lossless) echo. echo You can use: echo - Audacity (free): https://www.audacityteam.org/ echo - VLC Media Player (free): Media ^> Convert/Save -echo - Any audio converter software +echo - ffmpeg (CLI): ffmpeg -i input.mp3 -c:a libvorbis -qscale:a 4 output.ogg echo. pause diff --git a/convert_to_ogg.ps1 b/convert_to_ogg.ps1 new file mode 100644 index 0000000..35b5da8 --- /dev/null +++ b/convert_to_ogg.ps1 @@ -0,0 +1,45 @@ +# Convert MP3 sound effects to OGG Vorbis format for cross-platform playback +# Requires ffmpeg (https://ffmpeg.org/). OGG keeps files small while SDL's decoders +# work everywhere the game ships. + +$musicDir = "assets\music" +$sourceFiles = @( + "amazing.mp3", + "boom_tetris.mp3", + "great_move.mp3", + "impressive.mp3", + "keep_that_ryhtm.mp3", + "lets_go.mp3", + "nice_combo.mp3", + "smooth_clear.mp3", + "triple_strike.mp3", + "well_played.mp3", + "wonderful.mp3", + "you_fire.mp3", + "you_re_unstoppable.mp3", + "hard_drop_001.mp3", + "new_level.mp3", + "Every Block You Take.mp3" +) + +if (!(Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Host "ffmpeg is required. Install via https://ffmpeg.org/ or winget install Gyan.FFmpeg" -ForegroundColor Red + exit 1 +} + +Write-Host "Converting MP3 sound effects to OGG..." -ForegroundColor Cyan +foreach ($file in $sourceFiles) { + $src = Join-Path $musicDir $file + if (!(Test-Path $src)) { + Write-Host "Skipping (missing): $src" -ForegroundColor Yellow + continue + } + $ogg = ($file -replace ".mp3$", ".ogg") + $dest = Join-Path $musicDir $ogg + Write-Host "-> $ogg" -ForegroundColor Green + & ffmpeg -y -i $src -c:a libvorbis -qscale:a 4 $dest > $null 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to convert $file" -ForegroundColor Red + } +} +Write-Host "Conversion complete." -ForegroundColor Green diff --git a/convert_to_wav.ps1 b/convert_to_wav.ps1 index 577f52b..d77e27f 100644 --- a/convert_to_wav.ps1 +++ b/convert_to_wav.ps1 @@ -1,63 +1,11 @@ -# Convert MP3 sound effects to WAV format -# This script converts all MP3 sound effect files to WAV for better compatibility +# Deprecated shim: point developers to the OGG conversion workflow +Write-Host "convert_to_wav.ps1 is deprecated." -ForegroundColor Yellow +Write-Host "Use convert_to_ogg.ps1 for generating assets with OGG Vorbis." -ForegroundColor Yellow -$musicDir = "assets\music" -$mp3Files = @( - "amazing.mp3", - "boom_tetris.mp3", - "great_move.mp3", - "impressive.mp3", - "keep_that_ryhtm.mp3", - "lets_go.mp3", - "nice_combo.mp3", - "smooth_clear.mp3", - "triple_strike.mp3", - "well_played.mp3", - "wonderful.mp3", - "you_fire.mp3", - "you_re_unstoppable.mp3" -) - -Write-Host "Converting MP3 sound effects to WAV format..." -ForegroundColor Green - -foreach ($mp3File in $mp3Files) { - $mp3Path = Join-Path $musicDir $mp3File - $wavFile = $mp3File -replace "\.mp3$", ".wav" - $wavPath = Join-Path $musicDir $wavFile - - if (Test-Path $mp3Path) { - Write-Host "Converting $mp3File to $wavFile..." -ForegroundColor Yellow - - # Try ffmpeg first (most common) - $ffmpegResult = $null - try { - $ffmpegResult = & ffmpeg -i $mp3Path -acodec pcm_s16le -ar 44100 -ac 2 $wavPath -y 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "✓ Successfully converted $mp3File" -ForegroundColor Green - continue - } - } catch { - # FFmpeg not available, try other methods - } - - # Try Windows Media Format SDK (if available) - try { - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName Microsoft.VisualBasic - - # Use Windows built-in audio conversion - $shell = New-Object -ComObject Shell.Application - # This is a fallback method - may not work on all systems - Write-Host "⚠ FFmpeg not found. Please install FFmpeg or convert manually." -ForegroundColor Red - } catch { - Write-Host "⚠ Could not convert $mp3File automatically." -ForegroundColor Red - } - } else { - Write-Host "⚠ File not found: $mp3Path" -ForegroundColor Red - } +$oggScript = Join-Path $PSScriptRoot "convert_to_ogg.ps1" +if (Test-Path $oggScript) { + & $oggScript +} else { + Write-Host "Missing convert_to_ogg.ps1" -ForegroundColor Red + exit 1 } - -Write-Host "`nConversion complete! If FFmpeg was not found, please:" -ForegroundColor Cyan -Write-Host "1. Install FFmpeg: https://ffmpeg.org/download.html" -ForegroundColor White -Write-Host "2. Or use an audio converter like Audacity" -ForegroundColor White -Write-Host "3. Convert to: 44.1kHz, 16-bit, Stereo WAV" -ForegroundColor White diff --git a/cooperate_mode_plan.md b/cooperate_mode_plan.md new file mode 100644 index 0000000..b3a61e1 --- /dev/null +++ b/cooperate_mode_plan.md @@ -0,0 +1,174 @@ +# Spacetris — COOPERATE Mode (Two-Player Co-Op) +## VS Code Copilot AI Agent Prompt + +> Implement a new **COOPERATE** play mode for Spacetris. +> This is a **two-player cooperative mode** with a shared board and synchronized line clears. + +--- + +## 1. Mode Overview + +### Mode Name +- **COOPERATE** + +### Core Concept +- Two players play **together**, not versus. +- One shared game board with **double width**. +- Each player is responsible for **their own half** of the board. +- A line clears **only when BOTH halves of the same row are full**. + +--- + +## 2. Grid Layout + +- Grid width: **20 columns** +- Grid height: **standard height** (same as Endless/Challenge) +- Column ownership: + - Player 1 → columns `0–9` (left half) + - Player 2 → columns `10–19` (right half) + +### Visual Requirements +- Draw a **vertical divider line** between columns 9 and 10. +- Divider should be subtle but always visible. + +--- + +## 3. Line Clear Rule (Critical) + +A row clears **only if**: +- Player 1 half of the row is completely filled +- AND Player 2 half of the row is completely filled + +If only one side is filled: +- The row **does NOT clear** +- Provide visual feedback: + - Glow or pulse on the completed half + - Optional hint on the incomplete half + +--- + +## 4. Player Mechanics + +### Piece Streams +- Each player has their **own active piece** +- Each player has their **own NEXT queue** +- Both queues use the **same RNG seed** for fairness + +### Controls +- Player 1 controls only the left half +- Player 2 controls only the right half +- Players cannot move or rotate pieces into the other player’s half + +--- + +## 5. Gravity & Timing + +- Gravity applies globally (same speed for both) +- Lock delay is handled **per player** +- One player locking does NOT block the other player + +--- + +## 6. Failure Conditions + +Recommended: +- Game over occurs only when **both players top out** + +Alternative (optional): +- If either player tops out → shared loss + +--- + +## 7. Scoring System + +- Score is **shared** +- Line clears grant: + - Base score + - Cooperative bonus if both halves complete simultaneously + - Combo bonus for consecutive cooperative clears + +No competitive scoring between players. + +--- + +## 8. UI / HUD Requirements + +- Two NEXT panels (one per player) +- Shared score display +- Shared level display +- Visual feedback for: + - Half-filled rows + - Successful cooperative clears + +Optional: +- SYNC meter for advanced cooperative mechanics (future) + +--- + +## 9. Data Model Suggestions + +```cpp +enum class PlayerSide { + Left, + Right +}; + +struct PlayerState { + PlayerSide side; + ActivePiece piece; + bool isAlive; +}; + +struct Cell { + bool occupied; + PlayerSide owner; +}; +```` + +--- + +## 10. Line Clear Algorithm (Guidance) + +When checking for full rows: + +1. For each row: + + * Check columns `0–9` for full (Player 1) + * Check columns `10–19` for full (Player 2) +2. Only if **both are full**, mark row for clearing +3. Clear row normally and apply gravity to both halves +4. Update shared score and combos + +--- + +## 11. Constraints + +* COOPERATE is a separate play mode +* Do NOT reuse versus or garbage mechanics +* Focus on clarity, fairness, and readability +* Keep implementation modular (easy to expand later) + +--- + +## 12. Acceptance Criteria + +* Two players can play simultaneously on one board +* Each player fills only their half +* Lines clear only when both halves are filled +* Visual feedback clearly shows cooperative dependency +* Mode integrates cleanly into the main menu + +--- + +## 13. Optional Future Hooks (Do Not Implement Now) + +* Assist blocks +* Shared power-ups +* Cross-half interactions +* Online co-op + +--- + +## Short Summary for the Agent + +Implement a two-player COOPERATE mode with a 20-column board split into two halves. Each player fills their half independently. A line clears only when both halves of the same row are full. Score, level, and progress are shared. Add clear visual feedback and a divider between player halves. diff --git a/docs/CODE_ORGANIZATION.md b/docs/CODE_ORGANIZATION.md new file mode 100644 index 0000000..14b0a8b --- /dev/null +++ b/docs/CODE_ORGANIZATION.md @@ -0,0 +1,425 @@ +# Code Organization & Structure Improvements + +## ✅ Progress Tracker + +### Phase 1: Core Reorganization - IN PROGRESS ⚠️ + +**✅ Completed:** + +- ✅ Created new directory structure (interfaces/, application/, assets/, input/, state/, memory/) +- ✅ Created core interfaces (IRenderer.h, IAudioSystem.h, IAssetLoader.h, IInputHandler.h, IGameRules.h) +- ✅ Created ServiceContainer for dependency injection +- ✅ Moved ApplicationManager to core/application/ +- ✅ Moved AssetManager to core/assets/ +- ✅ Moved InputManager to core/input/ +- ✅ Moved StateManager to core/state/ +- ✅ Moved Game files to gameplay/core/ +- ✅ Moved Font files to graphics/ui/ +- ✅ Moved Starfield files to graphics/effects/ +- ✅ Moved RenderManager and GameRenderer to graphics/renderers/ +- ✅ Moved LineEffect to gameplay/effects/ +- ✅ Cleaned up duplicate files +- ✅ Audio and Scores files already properly located + +**⚠️ Currently In Progress:** + +- ✅ Updated critical include paths in main.cpp, state files, graphics renderers +- ✅ Fixed RenderManager duplicate method declarations +- ✅ Resolved GameRenderer.h and LoadingState.cpp include paths +- ⚠️ Still fixing remaining include path issues (ongoing) +- ⚠️ Still debugging Game.h redefinition errors (ongoing) + +**❌ Next Steps:** + +- ❌ Complete all remaining #include statement updates +- ❌ Resolve Game.h redefinition compilation errors +- ❌ Test successful compilation of both tetris and tetris_refactored targets +- ❌ Update documentation +- ❌ Begin Phase 2 - Interface implementation + +### Phase 2: Interface Extraction - NOT STARTED ❌ + +### Phase 3: Module Separation - NOT STARTED ❌ + +### Phase 4: Documentation & Standards - NOT STARTED ❌ + +## Current Structure Analysis + +### Strengths + +- ✅ Clear domain separation (core/, gameplay/, graphics/, audio/, etc.) +- ✅ Consistent naming conventions +- ✅ Modern C++ header organization +- ✅ Proper forward declarations + +### Areas for Improvement + +- ⚠️ Some files in root src/ should be moved to appropriate subdirectories +- ⚠️ Missing interfaces/contracts +- ⚠️ Some circular dependencies +- ⚠️ CMakeLists.txt has duplicate entries + +## Proposed Directory Restructure + +```text +src/ +├── core/ # Core engine systems +│ ├── interfaces/ # Abstract interfaces (NEW) +│ │ ├── IRenderer.h +│ │ ├── IAudioSystem.h +│ │ ├── IAssetLoader.h +│ │ ├── IInputHandler.h +│ │ └── IGameRules.h +│ ├── application/ # Application lifecycle (NEW) +│ │ ├── ApplicationManager.cpp/h +│ │ ├── ServiceContainer.cpp/h +│ │ └── SystemCoordinator.cpp/h +│ ├── assets/ # Asset management +│ │ ├── AssetManager.cpp/h +│ │ └── AssetLoader.cpp/h +│ ├── input/ # Input handling +│ │ └── InputManager.cpp/h +│ ├── state/ # State management +│ │ └── StateManager.cpp/h +│ ├── memory/ # Memory management (NEW) +│ │ ├── ObjectPool.h +│ │ └── MemoryTracker.h +│ └── Config.h +│ └── GlobalState.cpp/h +│ └── GravityManager.cpp/h +├── gameplay/ # Game logic +│ ├── core/ # Core game mechanics +│ │ ├── Game.cpp/h +│ │ ├── Board.cpp/h # Extract from Game.cpp +│ │ ├── Piece.cpp/h # Extract from Game.cpp +│ │ └── PieceFactory.cpp/h # Extract from Game.cpp +│ ├── rules/ # Game rules (NEW) +│ │ ├── ClassicTetrisRules.cpp/h +│ │ ├── ModernTetrisRules.cpp/h +│ │ └── ScoringSystem.cpp/h +│ ├── effects/ # Visual effects +│ │ └── LineEffect.cpp/h +│ └── mechanics/ # Game mechanics (NEW) +│ ├── RotationSystem.cpp/h +│ ├── KickTable.cpp/h +│ └── BagRandomizer.cpp/h +├── graphics/ # Rendering and visual +│ ├── renderers/ # Different renderers +│ │ ├── RenderManager.cpp/h +│ │ ├── GameRenderer.cpp/h +│ │ ├── UIRenderer.cpp/h # Extract from various places +│ │ └── EffectRenderer.cpp/h # New +│ ├── effects/ # Visual effects +│ │ ├── Starfield.cpp/h +│ │ ├── Starfield3D.cpp/h +│ │ └── ParticleSystem.cpp/h # New +│ ├── ui/ # UI components +│ │ ├── Font.cpp/h +│ │ ├── Button.cpp/h # New +│ │ ├── Panel.cpp/h # New +│ │ └── ScoreDisplay.cpp/h # New +│ └── resources/ # Graphics resources +│ ├── TextureAtlas.cpp/h # New +│ └── SpriteManager.cpp/h # New +├── audio/ # Audio system +│ ├── Audio.cpp/h +│ ├── SoundEffect.cpp/h +│ ├── MusicManager.cpp/h # New +│ └── AudioMixer.cpp/h # New +├── persistence/ # Data persistence +│ ├── Scores.cpp/h +│ ├── Settings.cpp/h # New +│ ├── SaveGame.cpp/h # New +│ └── Serialization.cpp/h # New +├── states/ # Game states +│ ├── State.h # Base interface +│ ├── LoadingState.cpp/h +│ ├── MenuState.cpp/h +│ ├── LevelSelectorState.cpp/h +│ ├── PlayingState.cpp/h +│ ├── PausedState.cpp/h # New +│ ├── GameOverState.cpp/h # New +│ └── SettingsState.cpp/h # New +├── network/ # Future: Multiplayer (NEW) +│ ├── NetworkManager.h +│ ├── Protocol.h +│ └── MultiplayerGame.h +├── utils/ # Utilities (NEW) +│ ├── Logger.cpp/h +│ ├── Timer.cpp/h +│ ├── MathUtils.h +│ └── StringUtils.h +├── platform/ # Platform-specific (NEW) +│ ├── Platform.h +│ ├── Windows/ +│ ├── Linux/ +│ └── macOS/ +└── main.cpp # Keep original main +└── main_new.cpp # Refactored main +``` + +## Module Dependencies + +### Clean Dependency Graph + +```text +Application Layer: main.cpp → ApplicationManager +Core Layer: ServiceContainer → All Managers +Gameplay Layer: Game → Rules → Mechanics +Graphics Layer: RenderManager → Renderers → Resources +Audio Layer: AudioSystem → Concrete Implementations +Persistence Layer: SaveSystem → Serialization +Platform Layer: Platform Abstraction (lowest level) +``` + +### Dependency Rules + +1. **No circular dependencies** +2. **Higher layers can depend on lower layers only** +3. **Use interfaces for cross-layer communication** +4. **Platform layer has no dependencies on other layers** + +## Header Organization + +### 1. Consistent Header Structure + +```cpp +// Standard template for all headers +#pragma once + +// System includes +#include +#include + +// External library includes +#include + +// Internal includes (from most general to most specific) +#include "core/interfaces/IRenderer.h" +#include "graphics/resources/Texture.h" +#include "MyClass.h" + +// Forward declarations +class GameRenderer; +class TextureAtlas; + +// Class definition +class MyClass { + // Public interface first +public: + // Constructors/Destructors + MyClass(); + ~MyClass(); + + // Core functionality + void update(double deltaTime); + void render(); + + // Getters/Setters + int getValue() const { return value; } + void setValue(int v) { value = v; } + +// Private implementation +private: + // Member variables + int value{0}; + std::unique_ptr renderer; + + // Private methods + void initializeRenderer(); +}; +``` + +### 2. Include Guards and PCH + +```cpp +// PrecompiledHeaders.h (NEW) +#pragma once + +// Standard library +#include +#include +#include +#include +#include +#include +#include + +// External libraries (stable) +#include +#include + +// Common project headers +#include "core/Config.h" +#include "core/interfaces/IRenderer.h" +``` + +## Code Style Improvements + +### 1. Consistent Naming Conventions + +```cpp +// Classes: PascalCase +class GameRenderer; +class TextureAtlas; + +// Functions/Methods: camelCase +void updateGameLogic(); +bool isValidPosition(); + +// Variables: camelCase +int currentScore; +double deltaTime; + +// Constants: UPPER_SNAKE_CASE +const int MAX_LEVEL = 30; +const double GRAVITY_MULTIPLIER = 1.0; + +// Private members: camelCase with suffix +class MyClass { +private: + int memberVariable_; // or m_memberVariable + static int staticCounter_; +}; +``` + +### 2. Documentation Standards + +```cpp +/** + * @brief Manages the core game state and logic for Tetris + * + * The Game class handles piece movement, rotation, line clearing, + * and scoring according to classic Tetris rules. + * + * @example + * ```cpp + * Game game(startLevel); + * game.reset(0); + * game.move(-1); // Move left + * game.rotate(1); // Rotate clockwise + * ``` + */ +class Game { +public: + /** + * @brief Moves the current piece horizontally + * @param dx Direction to move (-1 for left, +1 for right) + * @return true if the move was successful, false if blocked + */ + bool move(int dx); + + /** + * @brief Gets the current score + * @return Current score value + * @note Score never decreases during gameplay + */ + int score() const noexcept { return score_; } +}; +``` + +## CMake Improvements + +### 1. Modular CMakeLists.txt + +```cmake +# CMakeLists.txt (main) +cmake_minimum_required(VERSION 3.20) +project(tetris_sdl3 LANGUAGES CXX) + +# Global settings +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Find packages +find_package(SDL3 CONFIG REQUIRED) +find_package(SDL3_ttf CONFIG REQUIRED) + +# Add subdirectories +add_subdirectory(src/core) +add_subdirectory(src/gameplay) +add_subdirectory(src/graphics) +add_subdirectory(src/audio) +add_subdirectory(src/persistence) +add_subdirectory(src/states) + +# Main executable +add_executable(tetris src/main.cpp) +target_link_libraries(tetris PRIVATE + tetris::core + tetris::gameplay + tetris::graphics + tetris::audio + tetris::persistence + tetris::states +) + +# Tests +if(BUILD_TESTING) + add_subdirectory(tests) +endif() +``` + +### 2. Module CMakeLists.txt + +```cmake +# src/core/CMakeLists.txt +add_library(tetris_core + ApplicationManager.cpp + StateManager.cpp + InputManager.cpp + AssetManager.cpp + GlobalState.cpp + GravityManager.cpp +) + +add_library(tetris::core ALIAS tetris_core) + +target_include_directories(tetris_core + PUBLIC ${CMAKE_SOURCE_DIR}/src + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(tetris_core + PUBLIC SDL3::SDL3 SDL3_ttf::SDL3_ttf +) + +# Export for use by other modules +target_compile_features(tetris_core PUBLIC cxx_std_20) +``` + +## Implementation Timeline + +### Phase 1: Core Reorganization (Week 1-2) + +1. Create new directory structure +2. Move files to appropriate locations +3. Update CMakeLists.txt files +4. Fix include paths + +### Phase 2: Interface Extraction (Week 3-4) + +1. Create interface headers +2. Update implementations to use interfaces +3. Add dependency injection container + +### Phase 3: Module Separation (Week 5-6) + +1. Split large classes (Game, ApplicationManager) +2. Create separate CMake modules +3. Establish clean dependency graph + +### Phase 4: Documentation & Standards (Week 7-8) + +1. Add comprehensive documentation +2. Implement coding standards +3. Add static analysis tools +4. Update build scripts + +## Benefits + +1. **Maintainability**: Clear module boundaries and responsibilities +2. **Testability**: Easy to mock and test individual components +3. **Scalability**: Easy to add new features without affecting existing code +4. **Team Development**: Multiple developers can work on different modules +5. **Code Reuse**: Modular design enables component reuse diff --git a/docs/PERFORMANCE_OPTIMIZATION.md b/docs/PERFORMANCE_OPTIMIZATION.md new file mode 100644 index 0000000..8dd0f91 --- /dev/null +++ b/docs/PERFORMANCE_OPTIMIZATION.md @@ -0,0 +1,163 @@ +# Performance Optimization Recommendations + +## Current Performance Analysis + +### Memory Management +- **Good**: Proper RAII patterns, smart pointers +- **Improvement**: Object pooling for frequently created/destroyed objects + +### Rendering Performance +- **Current**: SDL3 with immediate mode rendering +- **Optimization Opportunities**: Batch rendering, texture atlasing + +### Game Logic Performance +- **Current**: Simple collision detection, adequate for Tetris +- **Good**: Efficient board representation using flat array + +## Specific Optimizations + +### 1. Object Pooling for Game Pieces + +```cpp +// src/gameplay/PiecePool.h +class PiecePool { +private: + std::vector> available; + std::vector> inUse; + +public: + std::unique_ptr acquire(PieceType type); + void release(std::unique_ptr piece); + void preAllocate(size_t count); +}; +``` + +### 2. Texture Atlas for UI Elements + +```cpp +// src/graphics/TextureAtlas.h +class TextureAtlas { +private: + SDL_Texture* atlasTexture; + std::unordered_map regions; + +public: + void loadAtlas(const std::string& atlasPath, const std::string& configPath); + SDL_Rect getRegion(const std::string& name) const; + SDL_Texture* getTexture() const { return atlasTexture; } +}; +``` + +### 3. Batch Rendering System + +```cpp +// src/graphics/BatchRenderer.h +class BatchRenderer { +private: + struct RenderCommand { + SDL_Texture* texture; + SDL_Rect srcRect; + SDL_Rect dstRect; + }; + + std::vector commands; + +public: + void addSprite(SDL_Texture* texture, const SDL_Rect& src, const SDL_Rect& dst); + void flush(); + void clear(); +}; +``` + +### 4. Memory-Efficient Board Representation + +```cpp +// Current: std::array board (40 integers = 160 bytes) +// Optimized: Bitset representation for filled/empty + color array for occupied cells + +class OptimizedBoard { +private: + std::bitset occupied; // 25 bytes (200 bits) + std::array colors; // 200 bytes, but only for occupied cells + +public: + bool isOccupied(int x, int y) const; + uint8_t getColor(int x, int y) const; + void setCell(int x, int y, uint8_t color); + void clearCell(int x, int y); +}; +``` + +### 5. Cache-Friendly Data Structures + +```cpp +// Group related data together for better cache locality +struct GameState { + // Hot data (frequently accessed) + std::array board; + Piece currentPiece; + int score; + int level; + int lines; + + // Cold data (less frequently accessed) + std::vector bag; + Piece holdPiece; + bool gameOver; + bool paused; +}; +``` + +## Performance Measurement + +### 1. Add Profiling Infrastructure + +```cpp +// src/core/Profiler.h +class Profiler { +private: + std::unordered_map startTimes; + std::unordered_map averageTimes; + +public: + void beginTimer(const std::string& name); + void endTimer(const std::string& name); + void printStats(); +}; + +// Usage: +// profiler.beginTimer("GameLogic"); +// game.update(deltaTime); +// profiler.endTimer("GameLogic"); +``` + +### 2. Frame Rate Optimization + +```cpp +// Target 60 FPS with consistent frame timing +class FrameRateManager { +private: + std::chrono::high_resolution_clock::time_point lastFrame; + double targetFrameTime = 1000.0 / 60.0; // 16.67ms + +public: + void beginFrame(); + void endFrame(); + double getDeltaTime() const; + bool shouldSkipFrame() const; +}; +``` + +## Expected Performance Gains + +1. **Object Pooling**: 30-50% reduction in allocation overhead +2. **Texture Atlas**: 20-30% improvement in rendering performance +3. **Batch Rendering**: 40-60% reduction in draw calls +4. **Optimized Board**: 60% reduction in memory usage +5. **Cache Optimization**: 10-20% improvement in game logic performance + +## Implementation Priority + +1. **High Impact, Low Effort**: Profiling infrastructure, frame rate management +2. **Medium Impact, Medium Effort**: Object pooling, optimized board representation +3. **High Impact, High Effort**: Texture atlas, batch rendering system diff --git a/docs/REFACTORING_SOLID_PRINCIPLES.md b/docs/REFACTORING_SOLID_PRINCIPLES.md new file mode 100644 index 0000000..d0e03a1 --- /dev/null +++ b/docs/REFACTORING_SOLID_PRINCIPLES.md @@ -0,0 +1,128 @@ +# SOLID Principles Refactoring Plan + +## Current Architecture Issues + +### 1. Single Responsibility Principle (SRP) Violations +- `ApplicationManager` handles initialization, coordination, rendering coordination, and asset management +- `Game` class mixes game logic with some presentation concerns + +### 2. Open/Closed Principle (OCP) Opportunities +- Hard-coded piece types and behaviors +- Limited extensibility for new game modes or rule variations + +### 3. Dependency Inversion Principle (DIP) Missing +- Concrete dependencies instead of interfaces +- Direct instantiation rather than dependency injection + +## Proposed Improvements + +### 1. Extract Interfaces + +```cpp +// src/core/interfaces/IRenderer.h +class IRenderer { +public: + virtual ~IRenderer() = default; + virtual void clear(uint8_t r, uint8_t g, uint8_t b, uint8_t a) = 0; + virtual void present() = 0; + virtual SDL_Renderer* getSDLRenderer() = 0; +}; + +// src/core/interfaces/IAudioSystem.h +class IAudioSystem { +public: + virtual ~IAudioSystem() = default; + virtual void playSound(const std::string& name) = 0; + virtual void playMusic(const std::string& name) = 0; + virtual void setMasterVolume(float volume) = 0; +}; + +// src/core/interfaces/IAssetLoader.h +class IAssetLoader { +public: + virtual ~IAssetLoader() = default; + virtual SDL_Texture* loadTexture(const std::string& path) = 0; + virtual void loadFont(const std::string& name, const std::string& path, int size) = 0; +}; +``` + +### 2. Dependency Injection Container + +```cpp +// src/core/ServiceContainer.h +class ServiceContainer { +private: + std::unordered_map> services; + +public: + template + void registerService(std::shared_ptr service) { + services[std::type_index(typeid(T))] = service; + } + + template + std::shared_ptr getService() { + auto it = services.find(std::type_index(typeid(T))); + if (it != services.end()) { + return std::static_pointer_cast(it->second); + } + return nullptr; + } +}; +``` + +### 3. Break Down ApplicationManager + +```cpp +// src/core/ApplicationLifecycle.h +class ApplicationLifecycle { +public: + bool initialize(int argc, char* argv[]); + void run(); + void shutdown(); +}; + +// src/core/SystemCoordinator.h +class SystemCoordinator { +public: + void initializeSystems(ServiceContainer& container); + void updateSystems(double deltaTime); + void shutdownSystems(); +}; +``` + +### 4. Strategy Pattern for Game Rules + +```cpp +// src/gameplay/interfaces/IGameRules.h +class IGameRules { +public: + virtual ~IGameRules() = default; + virtual int calculateScore(int linesCleared, int level) = 0; + virtual double getGravitySpeed(int level) = 0; + virtual bool shouldLevelUp(int lines) = 0; +}; + +// src/gameplay/rules/ClassicTetrisRules.h +class ClassicTetrisRules : public IGameRules { +public: + int calculateScore(int linesCleared, int level) override; + double getGravitySpeed(int level) override; + bool shouldLevelUp(int lines) override; +}; +``` + +## Implementation Priority + +1. **Phase 1**: Extract core interfaces (IRenderer, IAudioSystem) +2. **Phase 2**: Implement dependency injection container +3. **Phase 3**: Break down ApplicationManager responsibilities +4. **Phase 4**: Add strategy patterns for game rules +5. **Phase 5**: Improve testability with mock implementations + +## Benefits + +- **Testability**: Easy to mock dependencies for unit tests +- **Extensibility**: New features without modifying existing code +- **Maintainability**: Clear responsibilities and loose coupling +- **Flexibility**: Easy to swap implementations (e.g., different renderers) diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md new file mode 100644 index 0000000..e90bea5 --- /dev/null +++ b/docs/TESTING_STRATEGY.md @@ -0,0 +1,309 @@ +# Testing Strategy Enhancement + +## Current Testing State + +### Existing Tests +- ✅ GravityTests.cpp - Basic gravity manager testing +- ✅ Catch2 framework integration +- ✅ CTest integration in CMake + +### Coverage Gaps +- ❌ Game logic testing (piece movement, rotation, line clearing) +- ❌ Collision detection testing +- ❌ Scoring system testing +- ❌ State management testing +- ❌ Integration tests +- ❌ Performance tests + +## Comprehensive Testing Strategy + +### 1. Unit Tests Expansion + +```cpp +// tests/GameLogicTests.cpp +TEST_CASE("Piece Movement", "[game][movement]") { + Game game(0); + Piece originalPiece = game.current(); + + SECTION("Move left when possible") { + game.move(-1); + REQUIRE(game.current().x == originalPiece.x - 1); + } + + SECTION("Cannot move left at boundary") { + // Move piece to left edge + while (game.current().x > 0) { + game.move(-1); + } + int edgeX = game.current().x; + game.move(-1); + REQUIRE(game.current().x == edgeX); // Should not move further + } +} + +// tests/CollisionTests.cpp +TEST_CASE("Collision Detection", "[game][collision]") { + Game game(0); + + SECTION("Piece collides with bottom") { + // Force piece to bottom + while (!game.isGameOver()) { + game.hardDrop(); + if (game.isGameOver()) break; + } + // Verify collision behavior + } + + SECTION("Piece collides with placed blocks") { + // Place a block manually + // Test collision with new piece + } +} + +// tests/ScoringTests.cpp +TEST_CASE("Scoring System", "[game][scoring]") { + Game game(0); + int initialScore = game.score(); + + SECTION("Single line clear") { + // Set up board with almost complete line + // Clear line and verify score increase + } + + SECTION("Tetris (4 lines)") { + // Set up board for Tetris + // Verify bonus scoring + } +} +``` + +### 2. Mock Objects for Testing + +```cpp +// tests/mocks/MockRenderer.h +class MockRenderer : public IRenderer { +private: + mutable std::vector calls; + +public: + void clear(uint8_t r, uint8_t g, uint8_t b, uint8_t a) override { + calls.push_back("clear"); + } + + void present() override { + calls.push_back("present"); + } + + SDL_Renderer* getSDLRenderer() override { + return nullptr; // Mock implementation + } + + const std::vector& getCalls() const { return calls; } + void clearCalls() { calls.clear(); } +}; + +// tests/mocks/MockAudioSystem.h +class MockAudioSystem : public IAudioSystem { +private: + std::vector playedSounds; + +public: + void playSound(const std::string& name) override { + playedSounds.push_back(name); + } + + void playMusic(const std::string& name) override { + playedSounds.push_back("music:" + name); + } + + void setMasterVolume(float volume) override { + // Mock implementation + } + + const std::vector& getPlayedSounds() const { return playedSounds; } +}; +``` + +### 3. Integration Tests + +```cpp +// tests/integration/StateTransitionTests.cpp +TEST_CASE("State Transitions", "[integration][states]") { + ApplicationManager app; + // Mock dependencies + + SECTION("Loading to Menu transition") { + // Simulate loading completion + // Verify menu state activation + } + + SECTION("Menu to Game transition") { + // Simulate start game action + // Verify game state initialization + } +} + +// tests/integration/GamePlayTests.cpp +TEST_CASE("Complete Game Session", "[integration][gameplay]") { + Game game(0); + + SECTION("Play until first line clear") { + // Simulate complete game session + // Verify all systems work together + } +} +``` + +### 4. Performance Tests + +```cpp +// tests/performance/PerformanceTests.cpp +TEST_CASE("Game Logic Performance", "[performance]") { + Game game(0); + + SECTION("1000 piece drops should complete in reasonable time") { + auto start = std::chrono::high_resolution_clock::now(); + + for (int i = 0; i < 1000; ++i) { + game.hardDrop(); + if (game.isGameOver()) { + game.reset(0); + } + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + REQUIRE(duration.count() < 100); // Should complete in under 100ms + } +} + +// tests/performance/MemoryTests.cpp +TEST_CASE("Memory Usage", "[performance][memory]") { + SECTION("No memory leaks during gameplay") { + size_t initialMemory = getCurrentMemoryUsage(); + + { + Game game(0); + // Simulate gameplay + for (int i = 0; i < 100; ++i) { + game.hardDrop(); + if (game.isGameOver()) game.reset(0); + } + } + + size_t finalMemory = getCurrentMemoryUsage(); + REQUIRE(finalMemory <= initialMemory + 1024); // Allow small overhead + } +} +``` + +### 5. Property-Based Testing + +```cpp +// tests/property/PropertyTests.cpp +TEST_CASE("Property: Game state consistency", "[property]") { + Game game(0); + + SECTION("Score never decreases") { + int previousScore = game.score(); + + // Perform random valid actions + for (int i = 0; i < 100; ++i) { + performRandomValidAction(game); + REQUIRE(game.score() >= previousScore); + previousScore = game.score(); + } + } + + SECTION("Board state remains valid") { + for (int i = 0; i < 1000; ++i) { + performRandomValidAction(game); + REQUIRE(isBoardStateValid(game)); + } + } +} +``` + +### 6. Test Data Management + +```cpp +// tests/fixtures/GameFixtures.h +class GameFixtures { +public: + static Game createGameWithAlmostFullLine() { + Game game(0); + // Set up specific board state + return game; + } + + static Game createGameNearGameOver() { + Game game(0); + // Fill board almost to top + return game; + } + + static std::vector createTetrisPieceSequence() { + return {I, O, T, S, Z, J, L}; + } +}; +``` + +## Test Automation & CI + +### 1. GitHub Actions Configuration + +```yaml +# .github/workflows/tests.yml +name: Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + build-type: [Debug, Release] + + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + # Install vcpkg and dependencies + - name: Configure CMake + run: cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} + - name: Build + run: cmake --build build --config ${{ matrix.build-type }} + - name: Test + run: ctest --test-dir build --build-config ${{ matrix.build-type }} +``` + +### 2. Code Coverage + +```cmake +# Add to CMakeLists.txt +option(ENABLE_COVERAGE "Enable code coverage" OFF) + +if(ENABLE_COVERAGE) + target_compile_options(tetris PRIVATE --coverage) + target_link_libraries(tetris PRIVATE --coverage) +endif() +``` + +## Quality Metrics Targets + +- **Unit Test Coverage**: > 80% +- **Integration Test Coverage**: > 60% +- **Performance Regression**: < 5% per release +- **Memory Leak Detection**: 0 leaks in test suite +- **Static Analysis**: 0 critical issues + +## Implementation Priority + +1. **Phase 1**: Core game logic unit tests (movement, rotation, collision) +2. **Phase 2**: Mock objects and dependency injection for testability +3. **Phase 3**: Integration tests for state management +4. **Phase 4**: Performance and memory tests +5. **Phase 5**: Property-based testing and fuzzing +6. **Phase 6**: CI/CD pipeline with automated testing diff --git a/docs/ai/cooperate_network.md b/docs/ai/cooperate_network.md new file mode 100644 index 0000000..36f9882 --- /dev/null +++ b/docs/ai/cooperate_network.md @@ -0,0 +1,271 @@ +# Spacetris — COOPERATE Mode +## Network Multiplayer (2 PLAYER – NETWORK) +### VS Code Copilot AI Agent Prompt + +You are integrating **online cooperative multiplayer** into an existing **C++ / SDL3 game** called **Spacetris**. + +This feature extends the existing **COOPERATE mode** to support: +- Local 2 players +- Human + AI +- **Human + Human over network (NEW)** + +The networking solution must be **deterministic, lightweight, and stable**. + +--- + +## 1. High-Level Goal + +Add **COOPERATE 2 PLAYER (NETWORK)** mode where: +- Two players play together over the internet +- Each player controls one half of the shared grid +- A line clears only when both halves are filled +- Gameplay remains identical to local COOPERATE mode + +--- + +## 2. Technology Constraints + +- Language: **C++** +- Engine: **SDL3** +- Networking: **ENet (UDP with reliability)** +- No engine rewrite +- No authoritative server logic required (co-op only) + +SDL3 is used ONLY for: +- Rendering +- Input +- Timing + +Networking is a **separate layer**. + +--- + +## 3. Network Model (MANDATORY) + +### Use **Input Lockstep Networking** + +#### Core idea: +- Both clients run the same deterministic simulation +- Only **player inputs** are sent over the network +- No board state is transmitted +- Both simulations must remain identical + +This model is ideal for Tetris-like games. + +--- + +## 4. Determinism Requirements (CRITICAL) + +To ensure lockstep works: + +- Fixed simulation tick (e.g. 60 Hz) +- Identical RNG seed for both clients +- Deterministic piece generation (bag system) +- No floating-point math in core gameplay +- Same gravity, rotation, lock-delay logic +- Identical line clear and scoring rules + +Before networking: +- Input recording + replay must produce identical results + +--- + +## 5. Network Topology + +### Host / Client Model (Initial Implementation) + +- One player hosts the game +- One player joins +- Host is authoritative for: + - RNG seed + - start tick + - game settings + +This is sufficient and fair for cooperative gameplay. + +--- + +## 6. Network Library + +Use **ENet** for: +- Reliable, ordered UDP packets +- Low latency +- Simple integration with C++ + +Do NOT use: +- SDL_net +- TCP-only networking +- High-level matchmaking SDKs + +--- + +## 7. Network Packet Design + +### Input Packet (Minimal) + +```cpp +struct InputPacket { + uint32_t tick; + uint8_t buttons; // bitmask +}; +```` + +Button bitmask example: + +* bit 0 → move left +* bit 1 → move right +* bit 2 → rotate +* bit 3 → soft drop +* bit 4 → hard drop +* bit 5 → hold + +Packets must be: + +* Reliable +* Ordered +* Small + +--- + +## 8. Tick & Latency Handling + +### Input Delay Buffer (RECOMMENDED) + +* Add fixed delay: **4–6 ticks** +* Simulate tick `T` using inputs for `T + delay` +* Prevents stalls due to latency spikes + +Strict lockstep without buffering is NOT recommended. + +--- + +## 9. Desync Detection (IMPORTANT) + +Every N ticks (e.g. once per second): + +* Compute a hash of: + + * Both grid halves + * Active pieces + * RNG index + * Score / lines / level +* Exchange hashes +* If mismatch: + + * Log desync + * Stop game or mark session invalid + +This is required for debugging and stability. + +--- + +## 10. Network Session Architecture + +Create a dedicated networking module: + +``` +/network + NetSession.h + NetSession.cpp +``` + +Responsibilities: + +* ENet host/client setup +* Input packet send/receive +* Tick synchronization +* Latency buffering +* Disconnect handling + +SDL main loop must NOT block on networking. + +--- + +## 11. Integration with Existing COOPERATE Logic + +* COOPERATE grid logic stays unchanged +* SyncLineRenderer remains unchanged +* Scoring logic remains unchanged +* Network layer only injects **remote inputs** + +Game logic should not know whether partner is: + +* Local human +* AI +* Network player + +--- + +## 12. UI Integration (Menu Changes) + +In COOPERATE selection screen, add a new button: + +``` +[ LOCAL CO-OP ] [ AI PARTNER ] [ 2 PLAYER (NETWORK) ] +``` + +### On selecting 2 PLAYER (NETWORK): + +* Show: + + * Host Game + * Join Game +* Display join code or IP +* Confirm connection before starting + +--- + +## 13. Start Game Flow (Network) + +1. Host creates session +2. Client connects +3. Host sends: + + * RNG seed + * start tick + * game settings +4. Both wait until agreed start tick +5. Simulation begins simultaneously + +--- + +## 14. Disconnect & Error Handling + +* If connection drops: + + * Pause game + * Show “Reconnecting…” + * After timeout: + + * End match or switch to AI (optional) +* Never crash +* Never corrupt game state + +--- + +## 15. What NOT to Implement + +* ❌ Full state synchronization +* ❌ Prediction / rollback +* ❌ Server-authoritative gameplay +* ❌ Complex matchmaking +* ❌ Versus mechanics + +This is cooperative, not competitive. + +--- + +## 16. Acceptance Criteria + +* Two players can complete COOPERATE mode over network +* Gameplay matches local COOPERATE exactly +* No noticeable input lag under normal latency +* Desync detection works +* Offline / disconnect handled gracefully +* SDL3 render loop remains smooth + +--- + +## 17. Summary for Copilot + +Integrate networked cooperative multiplayer into Spacetris using SDL3 + C++ with ENet. Implement input lockstep networking with deterministic simulation, fixed tick rate, input buffering, and desync detection. Add a new COOPERATE menu option “2 PLAYER (NETWORK)” that allows host/join flow. Networking must be modular, non-blocking, and transparent to existing gameplay logic. diff --git a/package-quick.ps1 b/package-quick.ps1 index 48eebdb..6740734 100644 --- a/package-quick.ps1 +++ b/package-quick.ps1 @@ -1,7 +1,7 @@ #!/usr/bin/env pwsh <# .SYNOPSIS -Quick Production Package Creator for Tetris SDL3 +Quick Production Package Creator for Spacetris SDL3 .DESCRIPTION This script creates a production package using the existing build-msvc directory. @@ -12,8 +12,8 @@ param( [string]$OutputDir = "dist" ) -$ProjectName = "tetris" -$PackageDir = Join-Path $OutputDir "TetrisGame" +$ProjectName = "spacetris" +$PackageDir = Join-Path $OutputDir "SpacetrisGame" $Version = Get-Date -Format "yyyy.MM.dd" function Write-ColorOutput($ForegroundColor) { @@ -29,13 +29,13 @@ function Write-Warning { Write-ColorOutput Yellow $args } function Write-Error { Write-ColorOutput Red $args } Write-Info "======================================" -Write-Info " Tetris Quick Package Creator" +Write-Info " Spacetris Quick Package Creator" Write-Info "======================================" # Check if build exists -$ExecutablePath = "build-msvc\Debug\tetris.exe" +$ExecutablePath = "build-msvc\Debug\spacetris.exe" if (!(Test-Path $ExecutablePath)) { - $ExecutablePath = "build-msvc\Release\tetris.exe" + $ExecutablePath = "build-msvc\Release\spacetris.exe" if (!(Test-Path $ExecutablePath)) { Write-Error "No executable found in build-msvc directory. Please build the project first." exit 1 @@ -52,7 +52,7 @@ New-Item -ItemType Directory -Path $PackageDir -Force | Out-Null # Copy executable Copy-Item $ExecutablePath $PackageDir -Write-Success "Copied tetris.exe" +Write-Success "Copied spacetris.exe" # Copy assets if (Test-Path "assets") { @@ -82,7 +82,7 @@ if (Test-Path $VcpkgBin) { $LaunchContent = @" @echo off cd /d "%~dp0" -tetris.exe +spacetris.exe if %errorlevel% neq 0 ( echo. echo Game crashed or failed to start! @@ -91,22 +91,22 @@ if %errorlevel% neq 0 ( pause ) "@ -$LaunchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Tetris.bat") -Encoding ASCII -Write-Success "Created Launch-Tetris.bat" +$LaunchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Spacetris.bat") -Encoding ASCII +Write-Success "Created Launch-Spacetris.bat" # Create README $ReadmeContent = @" -Tetris SDL3 Game -================ +Spacetris SDL3 Game +=================== ## Quick Start -1. Run Launch-Tetris.bat or tetris.exe +1. Run Launch-Spacetris.bat or spacetris.exe 2. Use arrow keys to move, Z/X to rotate, Space to drop 3. Press F11 for fullscreen, Esc for menu ## Files -- tetris.exe: Main game -- Launch-Tetris.bat: Safe launcher with error handling +- spacetris.exe: Main game +- Launch-Spacetris.bat: Safe launcher with error handling - assets/: Game resources (music, images, fonts) - *.dll: Required libraries @@ -129,7 +129,7 @@ $PackageSize = (Get-ChildItem $PackageDir -Recurse | Measure-Object -Property Le $PackageSizeMB = [math]::Round($PackageSize / 1MB, 2) # Create ZIP -$ZipPath = Join-Path $OutputDir "TetrisGame-$Version.zip" +$ZipPath = Join-Path $OutputDir "SpacetrisGame-$Version.zip" try { Compress-Archive -Path $PackageDir -DestinationPath $ZipPath -Force Write-Success "Created ZIP: $ZipPath" diff --git a/scripts/check_braces.ps1 b/scripts/check_braces.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_comments.ps1 b/scripts/check_comments.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh new file mode 100644 index 0000000..ea0fff6 --- /dev/null +++ b/scripts/create-dmg.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create a distributable DMG for the macOS Spacetris app +# Usage: ./scripts/create-dmg.sh +# Example: ./scripts/create-dmg.sh dist/SpacetrisGame-mac/spacetris.app dist/SpacetrisGame.dmg + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + echo "Example: $0 dist/SpacetrisGame-mac/spacetris.app dist/SpacetrisGame.dmg" + exit 1 +fi + +APP_BUNDLE="$1" +OUTPUT_DMG="$2" + +if [[ ! -d "$APP_BUNDLE" ]]; then + echo "Error: App bundle not found at $APP_BUNDLE" >&2 + exit 1 +fi + +if [[ ! "$APP_BUNDLE" =~ \.app$ ]]; then + echo "Error: First argument must be a .app bundle" >&2 + exit 1 +fi + +# Remove existing DMG if present +rm -f "$OUTPUT_DMG" + +APP_NAME=$(basename "$APP_BUNDLE" .app) +VOLUME_NAME="$APP_NAME" +TEMP_DMG="${OUTPUT_DMG%.dmg}-temp.dmg" + +echo "[create-dmg] Creating temporary DMG..." +# Create a temporary read-write DMG (generous size to fit the app + padding) +hdiutil create -size 200m -fs HFS+ -volname "$VOLUME_NAME" "$TEMP_DMG" + +echo "[create-dmg] Mounting temporary DMG..." +MOUNT_DIR=$(hdiutil attach "$TEMP_DMG" -nobrowse | grep "/Volumes/$VOLUME_NAME" | awk '{print $3}') + +if [[ -z "$MOUNT_DIR" ]]; then + echo "Error: Failed to mount temporary DMG" >&2 + exit 1 +fi + +echo "[create-dmg] Copying app bundle to DMG..." +cp -R "$APP_BUNDLE" "$MOUNT_DIR/" + +# Create Applications symlink for drag-and-drop installation +echo "[create-dmg] Creating Applications symlink..." +ln -s /Applications "$MOUNT_DIR/Applications" + +# Set custom icon if available +VOLUME_ICON="$APP_BUNDLE/Contents/Resources/AppIcon.icns" +if [[ -f "$VOLUME_ICON" ]]; then + echo "[create-dmg] Setting custom volume icon..." + cp "$VOLUME_ICON" "$MOUNT_DIR/.VolumeIcon.icns" + SetFile -c icnC "$MOUNT_DIR/.VolumeIcon.icns" 2>/dev/null || true +fi + +# Unmount +echo "[create-dmg] Ejecting temporary DMG..." +hdiutil detach "$MOUNT_DIR" + +# Convert to compressed read-only DMG +echo "[create-dmg] Converting to compressed DMG..." +hdiutil convert "$TEMP_DMG" -format UDZO -o "$OUTPUT_DMG" + +# Cleanup +rm -f "$TEMP_DMG" + +echo "[create-dmg] Created: $OUTPUT_DMG" diff --git a/scripts/find_unmatched.ps1 b/scripts/find_unmatched.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/generate-mac-icon.sh b/scripts/generate-mac-icon.sh new file mode 100644 index 0000000..331a320 --- /dev/null +++ b/scripts/generate-mac-icon.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + exit 1 +fi + +ICON_SRC="$1" +ICON_DEST="$2" + +if [[ -f "$ICON_DEST" ]]; then + exit 0 +fi + +if [[ ! -f "$ICON_SRC" ]]; then + echo "[generate-mac-icon] Source icon not found: $ICON_SRC" >&2 + exit 1 +fi + +if ! command -v iconutil >/dev/null 2>&1; then + echo "[generate-mac-icon] iconutil not found" >&2 + exit 1 +fi + +TMPDIR=$(mktemp -d) +ICONSET="$TMPDIR/AppIcon.iconset" +mkdir -p "$ICONSET" + +for SIZE in 16 32 64 128 256 512; do + sips -s format png "$ICON_SRC" --resampleHeightWidth $SIZE $SIZE --out "$ICONSET/icon_${SIZE}x${SIZE}.png" >/dev/null + sips -s format png "$ICON_SRC" --resampleHeightWidth $((SIZE * 2)) $((SIZE * 2)) --out "$ICONSET/icon_${SIZE}x${SIZE}@2x.png" >/dev/null +done + +iconutil -c icns "$ICONSET" -o "$ICON_DEST" +rm -rf "$TMPDIR" + +if [[ -f "$ICON_DEST" ]]; then + echo "[generate-mac-icon] Generated $ICON_DEST" +else + echo "[generate-mac-icon] Failed to create $ICON_DEST" >&2 + exit 1 +fi diff --git a/settings.ini b/settings.ini new file mode 100644 index 0000000..99029f7 --- /dev/null +++ b/settings.ini @@ -0,0 +1,20 @@ +; Tetris Game Settings +; This file is auto-generated + +[Display] +Fullscreen=1 + +[Audio] +Music=1 +Sound=1 + +[Gameplay] +SmoothScroll=1 + +UpRotateClockwise=0 + +[Player] +Name=GREGOR + +[Debug] +Enabled=1 diff --git a/src/app/AssetLoader.cpp b/src/app/AssetLoader.cpp new file mode 100644 index 0000000..95d060e --- /dev/null +++ b/src/app/AssetLoader.cpp @@ -0,0 +1,139 @@ +#include "app/AssetLoader.h" +#include +#include + +AssetLoader::AssetLoader() = default; + +AssetLoader::~AssetLoader() { + shutdown(); +} + +void AssetLoader::init(SDL_Renderer* renderer) { + m_renderer = renderer; +} + +void AssetLoader::shutdown() { + // Destroy textures + { + std::lock_guard lk(m_texturesMutex); + for (auto &p : m_textures) { + if (p.second) SDL_DestroyTexture(p.second); + } + m_textures.clear(); + } + + // Clear queue and errors + { + std::lock_guard lk(m_queueMutex); + m_queue.clear(); + } + { + std::lock_guard lk(m_errorsMutex); + m_errors.clear(); + } + + m_totalTasks = 0; + m_loadedTasks = 0; + m_renderer = nullptr; +} + +void AssetLoader::setBasePath(const std::string& basePath) { + m_basePath = basePath; +} + +void AssetLoader::queueTexture(const std::string& path) { + { + std::lock_guard lk(m_queueMutex); + m_queue.push_back(path); + } + m_totalTasks.fetch_add(1, std::memory_order_relaxed); +} + +bool AssetLoader::performStep() { + std::string path; + { + std::lock_guard lk(m_queueMutex); + if (m_queue.empty()) return true; + path = m_queue.front(); + m_queue.erase(m_queue.begin()); + } + + { + std::lock_guard lk(m_currentLoadingMutex); + m_currentLoading = path; + } + + std::string fullPath = m_basePath.empty() ? path : (m_basePath + "/" + path); + + SDL_Surface* surf = IMG_Load(fullPath.c_str()); + if (!surf) { + std::lock_guard lk(m_errorsMutex); + m_errors.push_back(std::string("IMG_Load failed: ") + fullPath + " -> " + SDL_GetError()); + } else { + SDL_Texture* tex = SDL_CreateTextureFromSurface(m_renderer, surf); + SDL_DestroySurface(surf); + if (!tex) { + std::lock_guard lk(m_errorsMutex); + m_errors.push_back(std::string("CreateTexture failed: ") + fullPath); + } else { + std::lock_guard lk(m_texturesMutex); + auto& slot = m_textures[path]; + if (slot && slot != tex) { + SDL_DestroyTexture(slot); + } + slot = tex; + } + } + + m_loadedTasks.fetch_add(1, std::memory_order_relaxed); + + { + std::lock_guard lk(m_currentLoadingMutex); + m_currentLoading.clear(); + } + + // Return true when no more queued tasks + { + std::lock_guard lk(m_queueMutex); + return m_queue.empty(); + } +} + +void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) { + if (!texture) { + return; + } + + std::lock_guard lk(m_texturesMutex); + auto& slot = m_textures[path]; + if (slot && slot != texture) { + SDL_DestroyTexture(slot); + } + slot = texture; +} + +float AssetLoader::getProgress() const { + int total = m_totalTasks.load(std::memory_order_relaxed); + if (total <= 0) return 1.0f; + int loaded = m_loadedTasks.load(std::memory_order_relaxed); + return static_cast(loaded) / static_cast(total); +} + +std::vector AssetLoader::getAndClearErrors() { + std::lock_guard lk(m_errorsMutex); + std::vector out = m_errors; + m_errors.clear(); + return out; +} + +SDL_Texture* AssetLoader::getTexture(const std::string& path) const { + std::lock_guard lk(m_texturesMutex); + auto it = m_textures.find(path); + if (it == m_textures.end()) return nullptr; + return it->second; +} + +std::string AssetLoader::getCurrentLoading() const { + std::lock_guard lk(m_currentLoadingMutex); + return m_currentLoading; +} diff --git a/src/app/AssetLoader.h b/src/app/AssetLoader.h new file mode 100644 index 0000000..fac6128 --- /dev/null +++ b/src/app/AssetLoader.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Lightweight AssetLoader scaffold. +// Responsibilities: +// - Queue textures to load (main thread) and perform incremental loads via performStep(). +// - Store loaded SDL_Texture* instances and provide accessors. +// - Collect loading errors thread-safely. +// NOTE: All SDL texture creation MUST happen on the thread that owns the SDL_Renderer. +class AssetLoader { +public: + AssetLoader(); + ~AssetLoader(); + + void init(SDL_Renderer* renderer); + void shutdown(); + + void setBasePath(const std::string& basePath); + + // Queue a texture path (relative to base path) for loading. + void queueTexture(const std::string& path); + + // Perform a single loading step (load one queued asset). + // Returns true when all queued tasks are complete, false otherwise. + bool performStep(); + + // Progress in [0,1]. If no tasks, returns 1.0f. + float getProgress() const; + + // Retrieve and clear accumulated error messages. + std::vector getAndClearErrors(); + + // Get a loaded texture (or nullptr if not loaded). + SDL_Texture* getTexture(const std::string& path) const; + + // Adopt an externally-created texture so AssetLoader owns its lifetime. + // If a texture is already registered for this path, it will be replaced. + void adoptTexture(const std::string& path, SDL_Texture* texture); + + // Return currently-loading path (empty when idle). + std::string getCurrentLoading() const; + +private: + SDL_Renderer* m_renderer = nullptr; + std::string m_basePath; + + // queued paths (simple FIFO) + std::vector m_queue; + mutable std::mutex m_queueMutex; + + std::unordered_map m_textures; + mutable std::mutex m_texturesMutex; + + std::vector m_errors; + mutable std::mutex m_errorsMutex; + + std::atomic m_totalTasks{0}; + std::atomic m_loadedTasks{0}; + + std::string m_currentLoading; + mutable std::mutex m_currentLoadingMutex; +}; diff --git a/src/app/BackgroundManager.cpp b/src/app/BackgroundManager.cpp new file mode 100644 index 0000000..c043b39 --- /dev/null +++ b/src/app/BackgroundManager.cpp @@ -0,0 +1,165 @@ +#include "app/BackgroundManager.h" +#include +#include +#include +#include +#include +#include "utils/ImagePathResolver.h" + +struct BackgroundManager::Impl { + enum class Phase { Idle, ZoomOut, ZoomIn }; + SDL_Texture* currentTex = nullptr; + SDL_Texture* nextTex = nullptr; + int currentLevel = -1; + int queuedLevel = -1; + float phaseElapsedMs = 0.0f; + float phaseDurationMs = 0.0f; + float fadeDurationMs = 1200.0f; + Phase phase = Phase::Idle; +}; + +static float getPhaseDurationMs(const BackgroundManager::Impl& fader, BackgroundManager::Impl::Phase ph) { + const float total = std::max(1200.0f, fader.fadeDurationMs); + switch (ph) { + case BackgroundManager::Impl::Phase::ZoomOut: return total * 0.45f; + case BackgroundManager::Impl::Phase::ZoomIn: return total * 0.45f; + default: return 0.0f; + } +} + +static void destroyTex(SDL_Texture*& t) { + if (t) { SDL_DestroyTexture(t); t = nullptr; } +} + +BackgroundManager::BackgroundManager() : impl(new Impl()) {} +BackgroundManager::~BackgroundManager() { reset(); delete impl; impl = nullptr; } + +bool BackgroundManager::queueLevelBackground(SDL_Renderer* renderer, int level) { + if (!renderer) return false; + level = std::clamp(level, 0, 32); + if (impl->currentLevel == level || impl->queuedLevel == level) return true; + + char bgPath[256]; + std::snprintf(bgPath, sizeof(bgPath), "assets/images/levels/level%d.jpg", level); + const std::string resolved = AssetPath::resolveImagePath(bgPath); + + SDL_Surface* s = IMG_Load(resolved.c_str()); + if (!s) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Background load failed: %s (%s)", bgPath, resolved.c_str()); + return false; + } + SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer, s); + SDL_DestroySurface(s); + if (!tex) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "CreateTexture failed for %s", resolved.c_str()); + return false; + } + + destroyTex(impl->nextTex); + impl->nextTex = tex; + impl->queuedLevel = level; + + if (!impl->currentTex) { + impl->currentTex = impl->nextTex; + impl->currentLevel = impl->queuedLevel; + impl->nextTex = nullptr; + impl->queuedLevel = -1; + impl->phase = Impl::Phase::Idle; + impl->phaseElapsedMs = 0.0f; + impl->phaseDurationMs = 0.0f; + } else if (impl->phase == Impl::Phase::Idle) { + impl->phase = Impl::Phase::ZoomOut; + impl->phaseDurationMs = getPhaseDurationMs(*impl, impl->phase); + impl->phaseElapsedMs = 0.0f; + } + return true; +} + +void BackgroundManager::update(float frameMs) { + if (impl->phase == Impl::Phase::Idle) return; + if (!impl->currentTex && !impl->nextTex) { impl->phase = Impl::Phase::Idle; return; } + + impl->phaseElapsedMs += frameMs; + if (impl->phaseElapsedMs < std::max(1.0f, impl->phaseDurationMs)) return; + + if (impl->phase == Impl::Phase::ZoomOut) { + if (impl->nextTex) { + destroyTex(impl->currentTex); + impl->currentTex = impl->nextTex; + impl->currentLevel = impl->queuedLevel; + impl->nextTex = nullptr; + impl->queuedLevel = -1; + } + impl->phase = Impl::Phase::ZoomIn; + impl->phaseDurationMs = getPhaseDurationMs(*impl, impl->phase); + impl->phaseElapsedMs = 0.0f; + } else if (impl->phase == Impl::Phase::ZoomIn) { + impl->phase = Impl::Phase::Idle; + impl->phaseElapsedMs = 0.0f; + impl->phaseDurationMs = 0.0f; + } +} + +static void renderDynamic(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float baseScale, float motionClockMs, float alphaMul) { + if (!renderer || !tex) return; + const float seconds = motionClockMs * 0.001f; + const float wobble = std::max(0.4f, baseScale + std::sin(seconds * 0.07f) * 0.02f + std::sin(seconds * 0.23f) * 0.01f); + const float rotation = std::sin(seconds * 0.035f) * 1.25f; + const float panX = std::sin(seconds * 0.11f) * winW * 0.02f; + const float panY = std::cos(seconds * 0.09f) * winH * 0.015f; + SDL_FRect dest{ (winW - winW * wobble) * 0.5f + panX, (winH - winH * wobble) * 0.5f + panY, winW * wobble, winH * wobble }; + SDL_FPoint center{dest.w * 0.5f, dest.h * 0.5f}; + Uint8 alpha = static_cast(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f); + SDL_SetTextureAlphaMod(tex, alpha); + SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, ¢er, SDL_FLIP_NONE); + SDL_SetTextureAlphaMod(tex, 255); +} + +void BackgroundManager::render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs) { + if (!renderer) return; + SDL_FRect fullRect{0.f,0.f,(float)winW,(float)winH}; + float duration = std::max(1.0f, impl->phaseDurationMs); + float progress = (impl->phase == Impl::Phase::Idle) ? 0.0f : std::clamp(impl->phaseElapsedMs / duration, 0.0f, 1.0f); + const float seconds = motionClockMs * 0.001f; + + if (impl->phase == Impl::Phase::ZoomOut) { + float scale = 1.0f + progress * 0.15f; + if (impl->currentTex) { + renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f)); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0,0,0, Uint8(progress * 200.0f)); + SDL_RenderFillRect(renderer, &fullRect); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + } + } else if (impl->phase == Impl::Phase::ZoomIn) { + float scale = 1.10f - progress * 0.10f; + Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f); + if (impl->currentTex) { + renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f); + } + } else { + if (impl->currentTex) { + renderDynamic(renderer, impl->currentTex, winW, winH, 1.02f, motionClockMs, 1.0f); + float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f)); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 5,12,28, Uint8(pulse * 90.0f)); + SDL_RenderFillRect(renderer, &fullRect); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + } else if (impl->nextTex) { + renderDynamic(renderer, impl->nextTex, winW, winH, 1.02f, motionClockMs, 1.0f); + } else { + SDL_SetRenderDrawColor(renderer, 0,0,0,255); + SDL_RenderFillRect(renderer, &fullRect); + } + } +} + +void BackgroundManager::reset() { + destroyTex(impl->currentTex); + destroyTex(impl->nextTex); + impl->currentLevel = -1; + impl->queuedLevel = -1; + impl->phaseElapsedMs = 0.0f; + impl->phaseDurationMs = 0.0f; + impl->phase = Impl::Phase::Idle; +} diff --git a/src/app/BackgroundManager.h b/src/app/BackgroundManager.h new file mode 100644 index 0000000..dc53cb8 --- /dev/null +++ b/src/app/BackgroundManager.h @@ -0,0 +1,18 @@ +#pragma once +#include + +class BackgroundManager { +public: + BackgroundManager(); + ~BackgroundManager(); + + bool queueLevelBackground(SDL_Renderer* renderer, int level); + void update(float frameMs); + void render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs); + void reset(); + + struct Impl; + +private: + Impl* impl; +}; diff --git a/src/app/Fireworks.cpp b/src/app/Fireworks.cpp new file mode 100644 index 0000000..4e25ae1 --- /dev/null +++ b/src/app/Fireworks.cpp @@ -0,0 +1,150 @@ +#include "app/Fireworks.h" +#include +#include +#include +#include +#include + +namespace { +struct BlockParticle { + float x{}, y{}, vx{}, vy{}, size{}, alpha{}, decay{}, wobblePhase{}, wobbleSpeed{}, coreHeat{}; + BlockParticle(float sx, float sy) : x(sx), y(sy) { + const float spreadDeg = 35.0f; + const float angleDeg = -90.0f + spreadDeg * ((rand() % 200) / 100.0f - 1.0f); + const float angleRad = angleDeg * 3.1415926f / 180.0f; + float speed = 1.3f + (rand() % 220) / 80.0f; + vx = std::cos(angleRad) * speed * 0.55f; + vy = std::sin(angleRad) * speed; + size = 6.0f + (rand() % 40) / 10.0f; + alpha = 1.0f; + decay = 0.0095f + (rand() % 180) / 12000.0f; + wobblePhase = (rand() % 628) / 100.0f; + wobbleSpeed = 0.08f + (rand() % 60) / 600.0f; + coreHeat = 0.65f + (rand() % 35) / 100.0f; + } + bool update() { + vx *= 0.992f; + vy = vy * 0.985f - 0.015f; + x += vx; + y += vy; + wobblePhase += wobbleSpeed; + x += std::sin(wobblePhase) * 0.12f; + alpha -= decay; + size = std::max(1.8f, size - 0.03f); + coreHeat = std::max(0.0f, coreHeat - decay * 0.6f); + return alpha > 0.03f; + } +}; + +struct TetrisFirework { + std::vector particles; + TetrisFirework(float x, float y) { + int particleCount = 30 + rand() % 25; + particles.reserve(particleCount); + for (int i=0;iupdate()) it = particles.erase(it); + else ++it; + } + return !particles.empty(); + } +}; + +static std::vector fireworks; +static double logoAnimCounter = 0.0; +static int hoveredButton = -1; + +static SDL_Color blendFireColor(float heat, float alphaScale, Uint8 minG, Uint8 minB) { + heat = std::clamp(heat, 0.0f, 1.0f); + Uint8 r = 255; + Uint8 g = static_cast(std::clamp(120.0f + heat * (255.0f - 120.0f), float(minG), 255.0f)); + Uint8 b = static_cast(std::clamp(40.0f + (1.0f - heat) * 60.0f, float(minB), 255.0f)); + Uint8 a = static_cast(std::clamp(alphaScale * 255.0f, 0.0f, 255.0f)); + return SDL_Color{r,g,b,a}; +} +} // namespace + +namespace AppFireworks { +void update(double frameMs) { + if (fireworks.size() < 5 && (rand() % 100) < 2) { + float x = 1200.0f * 0.55f + float(rand() % int(1200.0f * 0.35f)); + float y = 1000.0f * 0.80f + float(rand() % int(1000.0f * 0.15f)); + fireworks.emplace_back(x,y); + } + for (auto it = fireworks.begin(); it != fireworks.end();) { + if (!it->update()) it = fireworks.erase(it); + else ++it; + } +} + +void draw(SDL_Renderer* renderer, SDL_Texture*) { + if (!renderer) return; + SDL_BlendMode previousBlend = SDL_BLENDMODE_NONE; + SDL_GetRenderDrawBlendMode(renderer, &previousBlend); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); + static constexpr int quadIdx[6] = {0,1,2,2,1,3}; + + auto makeV = [](float px, float py, SDL_Color c){ + SDL_Vertex v{}; + v.position.x = px; + v.position.y = py; + v.color = SDL_FColor{ c.r/255.0f, c.g/255.0f, c.b/255.0f, c.a/255.0f }; + return v; + }; + + for (auto& f : fireworks) { + for (auto& p : f.particles) { + const float heat = std::clamp(p.alpha * 1.25f + p.coreHeat * 0.5f, 0.0f, 1.0f); + SDL_Color glow = blendFireColor(0.45f + heat * 0.55f, p.alpha * 0.55f, 100, 40); + SDL_Color tailBase = blendFireColor(heat * 0.75f, p.alpha * 0.5f, 70, 25); + SDL_Color tailTip = blendFireColor(heat * 0.35f, p.alpha * 0.2f, 40, 15); + SDL_Color core = blendFireColor(heat, std::min(1.0f, p.alpha * 1.1f), 150, 80); + + float velLen = std::sqrt(p.vx*p.vx + p.vy*p.vy); + SDL_FPoint dir = velLen > 0.001f ? SDL_FPoint{p.vx/velLen,p.vy/velLen} : SDL_FPoint{0.0f,-1.0f}; + SDL_FPoint perp{-dir.y, dir.x}; + const float baseW = std::max(0.8f, p.size * 0.55f); + const float tipW = baseW * 0.35f; + const float tailLen = p.size * (3.0f + (1.0f - p.alpha) * 1.8f); + + SDL_FPoint base{p.x,p.y}; + SDL_FPoint tip{p.x + dir.x*tailLen, p.y + dir.y*tailLen}; + + SDL_Vertex tail[4]; + tail[0] = makeV(base.x + perp.x * baseW, base.y + perp.y * baseW, tailBase); + tail[1] = makeV(base.x - perp.x * baseW, base.y - perp.y * baseW, tailBase); + tail[2] = makeV(tip.x + perp.x * tipW, tip.y + perp.y * tipW, tailTip); + tail[3] = makeV(tip.x - perp.x * tipW, tip.y - perp.y * tipW, tailTip); + SDL_RenderGeometry(renderer, nullptr, tail, 4, quadIdx, 6); + + const float glowAlong = p.size * 0.95f; + const float glowAcross = p.size * 0.6f; + SDL_Vertex glowV[4]; + glowV[0] = makeV(base.x + dir.x * glowAlong, base.y + dir.y * glowAlong, glow); + glowV[1] = makeV(base.x - dir.x * glowAlong, base.y - dir.y * glowAlong, glow); + glowV[2] = makeV(base.x + perp.x * glowAcross, base.y + perp.y * glowAcross, glow); + glowV[3] = makeV(base.x - perp.x * glowAcross, base.y - perp.y * glowAcross, glow); + SDL_RenderGeometry(renderer, nullptr, glowV, 4, quadIdx, 6); + + const float coreW = p.size * 0.35f; + const float coreH = p.size * 0.9f; + SDL_Vertex coreV[4]; + coreV[0] = makeV(base.x + perp.x * coreW, base.y + perp.y * coreW, core); + coreV[1] = makeV(base.x - perp.x * coreW, base.y - perp.y * coreW, core); + coreV[2] = makeV(base.x + dir.x * coreH, base.y + dir.y * coreH, core); + coreV[3] = makeV(base.x - dir.x * coreH, base.y - dir.y * coreH, core); + SDL_RenderGeometry(renderer, nullptr, coreV, 4, quadIdx, 6); + } + } + + SDL_SetRenderDrawBlendMode(renderer, previousBlend); +} + +double getLogoAnimCounter() { return logoAnimCounter; } +int getHoveredButton() { return hoveredButton; } +void spawn(float x, float y) { + fireworks.emplace_back(x, y); +} +} // namespace AppFireworks diff --git a/src/app/Fireworks.h b/src/app/Fireworks.h new file mode 100644 index 0000000..c28b419 --- /dev/null +++ b/src/app/Fireworks.h @@ -0,0 +1,10 @@ +#pragma once +#include + +namespace AppFireworks { + void draw(SDL_Renderer* renderer, SDL_Texture* tex); + void update(double frameMs); + double getLogoAnimCounter(); + int getHoveredButton(); + void spawn(float x, float y); +} diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp new file mode 100644 index 0000000..0bd4d0b --- /dev/null +++ b/src/app/TetrisApp.cpp @@ -0,0 +1,2735 @@ +// TetrisApp.cpp - Main application runtime split out from main.cpp. +// +// This file is intentionally "orchestration-heavy": it wires together SDL, audio, +// asset loading, and the state machine. Keep gameplay mechanics in the gameplay/ +// and states/ modules. + +#include "app/TetrisApp.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/AssetLoader.h" +#include "app/BackgroundManager.h" +#include "app/Fireworks.h" +#include "app/TextureLoader.h" + +#include "audio/Audio.h" +#include "audio/MenuWrappers.h" +#include "audio/SoundEffect.h" + +#include "core/Config.h" +#include "core/Settings.h" +#include "core/state/StateManager.h" + +#include "gameplay/core/Game.h" +#include "gameplay/coop/CoopGame.h" +#include "gameplay/coop/CoopAIController.h" +#include "gameplay/effects/LineEffect.h" + +#include "graphics/effects/SpaceWarp.h" +#include "graphics/effects/Starfield.h" +#include "graphics/effects/Starfield3D.h" +#include "graphics/renderers/GameRenderer.h" +#include "graphics/renderers/RenderPrimitives.h" +#include "graphics/ui/Font.h" +#include "graphics/ui/HelpOverlay.h" + +#include "network/CoopNetButtons.h" +#include "network/NetSession.h" + +#include "persistence/Scores.h" + +#include "states/LevelSelectorState.h" +#include "states/LoadingManager.h" +#include "states/LoadingState.h" +#include "states/MenuState.h" +#include "states/OptionsState.h" +#include "states/PlayingState.h" +#include "states/VideoState.h" +#include "states/State.h" + +#include "ui/BottomMenu.h" +#include "../resources/AssetPaths.h" +#include "ui/MenuLayout.h" + +#include "utils/ImagePathResolver.h" + +// ---------- Game config ---------- +static constexpr int LOGICAL_W = 1200; +static constexpr int LOGICAL_H = 1000; +static constexpr int WELL_W = Game::COLS * Game::TILE; +static constexpr int WELL_H = Game::ROWS * Game::TILE; + +#include "ui/UIConstants.h" + +static const std::array COLORS = {{ + SDL_Color{20, 20, 26, 255}, // 0 empty + SDL_Color{0, 255, 255, 255}, // I + SDL_Color{255, 255, 0, 255}, // O + SDL_Color{160, 0, 255, 255}, // T + SDL_Color{0, 255, 0, 255}, // S + SDL_Color{255, 0, 0, 255}, // Z + SDL_Color{0, 0, 255, 255}, // J + 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; + std::mutex assetLoadErrorsMutex; + // Loading counters for progress UI and debug overlay + std::atomic totalLoadingTasks{0}; + std::atomic loadedTasks{0}; + std::string currentLoadingFile; + std::mutex currentLoadingMutex; + + // Intro/Menu shared state (wired into StateContext as pointers) + double logoAnimCounter = 0.0; + bool showSettingsPopup = false; + bool showHelpOverlay = false; + bool showExitConfirmPopup = false; + int exitPopupSelectedButton = 1; // 0 = YES, 1 = NO + bool musicEnabled = true; + int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings + bool isNewHighScore = false; + std::string playerName; + std::string player2Name; + int highScoreEntryIndex = 0; // 0 = entering player1, 1 = entering player2 + bool helpOverlayPausedGame = false; + + SDL_Window* window = nullptr; + SDL_Renderer* renderer = nullptr; + + AssetLoader assetLoader; + std::unique_ptr loadingManager; + std::unique_ptr textureLoader; + + FontAtlas pixelFont; + FontAtlas font; + + ScoreManager scores; + std::atomic scoresLoadComplete{false}; + std::jthread scoreLoader; + std::jthread menuTrackLoader; + + Starfield starfield; + Starfield3D starfield3D; + SpaceWarp spaceWarp; + SpaceWarpFlightMode warpFlightMode = SpaceWarpFlightMode::Forward; + bool warpAutoPilotEnabled = true; + + LineEffect lineEffect; + + SDL_Texture* logoTex = nullptr; + SDL_Texture* logoSmallTex = nullptr; + int logoSmallW = 0; + int logoSmallH = 0; + SDL_Texture* backgroundTex = nullptr; + SDL_Texture* mainScreenTex = nullptr; + int mainScreenW = 0; + int mainScreenH = 0; + + SDL_Texture* blocksTex = nullptr; + SDL_Texture* asteroidsTex = nullptr; + SDL_Texture* scorePanelTex = nullptr; + SDL_Texture* statisticsPanelTex = nullptr; + SDL_Texture* nextPanelTex = nullptr; + SDL_Texture* holdPanelTex = nullptr; + + BackgroundManager levelBackgrounds; + int startLevelSelection = 0; + + // Music loading tracking + int totalTracks = 0; + int currentTrackLoading = 0; + bool musicLoaded = false; + bool musicStarted = false; + bool musicLoadingStarted = false; + + // Loader control: execute incrementally on main thread to avoid SDL threading issues + std::atomic_bool loadingStarted{false}; + std::atomic_bool loadingComplete{false}; + std::atomic loadingStep{0}; + + std::unique_ptr game; + std::unique_ptr coopGame; + std::vector singleSounds; + std::vector doubleSounds; + std::vector tripleSounds; + std::vector tetrisSounds; + bool suppressLineVoiceForLevelUp = false; + bool skipNextLevelUpJingle = false; + + // COOPERATE option: when true, right player is AI-controlled. + bool coopVsAI = false; + + CoopAIController coopAI; + + AppState state = AppState::Loading; + double loadingProgress = 0.0; + Uint64 loadStart = 0; + bool running = true; + bool isFullscreen = false; + bool leftHeld = false; + bool rightHeld = false; + bool p1LeftHeld = false; + bool p1RightHeld = false; + bool p2LeftHeld = false; + bool p2RightHeld = false; + double moveTimerMs = 0.0; + double p1MoveTimerMs = 0.0; + double p2MoveTimerMs = 0.0; + + // Network coop fixed-tick state (used only when ctx.coopNetEnabled is true) + double coopNetAccMs = 0.0; + uint32_t coopNetCachedTick = 0xFFFFFFFFu; + uint8_t coopNetCachedButtons = 0; + uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu; + double DAS = 170.0; + double ARR = 40.0; + SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; + float logicalScale = 1.f; + Uint64 lastMs = 0; + + enum class MenuFadePhase { None, FadeOut, FadeIn }; + MenuFadePhase menuFadePhase = MenuFadePhase::None; + double menuFadeClockMs = 0.0; + 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{}; + std::unique_ptr loadingState; + std::unique_ptr videoState; + std::unique_ptr menuState; + std::unique_ptr optionsState; + std::unique_ptr levelSelectorState; + std::unique_ptr playingState; + + // Startup fade-in overlay (used after intro video). + bool startupFadeActive = false; + float startupFadeAlpha = 0.0f; // 0..1 black overlay strength + double startupFadeClockMs = 0.0; + static constexpr double STARTUP_FADE_IN_MS = 650.0; + + // Intro video path. + std::string introVideoPath = "assets/videos/spacetris_intro.mp4"; + + int init(); + void runLoop(); + void shutdown(); +}; + +TetrisApp::TetrisApp() + : impl_(std::make_unique()) +{ +} + +TetrisApp::~TetrisApp() = default; + +int TetrisApp::run() +{ + const int initRc = impl_->init(); + if (initRc != 0) { + impl_->shutdown(); + return initRc; + } + + impl_->runLoop(); + impl_->shutdown(); + return 0; +} + +int TetrisApp::Impl::init() +{ + // Initialize random seed for procedural effects + srand(static_cast(SDL_GetTicks())); + + // Load settings + Settings::instance().load(); + + // Sync shared variables with settings + musicEnabled = Settings::instance().isMusicEnabled(); + playerName = Settings::instance().getPlayerName(); + if (playerName.empty()) playerName = "Player"; + + // Apply sound settings to manager + SoundEffectManager::instance().setEnabled(Settings::instance().isSoundEnabled()); + + int sdlInitRes = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO); + if (sdlInitRes < 0) + { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed: %s", SDL_GetError()); + return 1; + } + int ttfInitRes = TTF_Init(); + if (ttfInitRes < 0) + { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "TTF_Init failed"); + SDL_Quit(); + return 1; + } + + SDL_WindowFlags windowFlags = SDL_WINDOW_RESIZABLE; + if (Settings::instance().isFullscreen()) { + windowFlags |= SDL_WINDOW_FULLSCREEN; + } + + window = SDL_CreateWindow("SpaceTris (SDL3)", LOGICAL_W, LOGICAL_H, windowFlags); + if (!window) + { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateWindow failed: %s", SDL_GetError()); + TTF_Quit(); + SDL_Quit(); + return 1; + } + renderer = SDL_CreateRenderer(window, nullptr); + if (!renderer) + { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_CreateRenderer failed: %s", SDL_GetError()); + SDL_DestroyWindow(window); + window = nullptr; + TTF_Quit(); + SDL_Quit(); + return 1; + } + SDL_SetRenderVSync(renderer, 1); + + if (const char* basePathRaw = SDL_GetBasePath()) { + std::filesystem::path exeDir(basePathRaw); + AssetPath::setBasePath(exeDir.string()); +#if defined(__APPLE__) + // On macOS bundles launched from Finder start in /, so re-root relative paths. + std::error_code ec; + std::filesystem::current_path(exeDir, ec); + if (ec) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Failed to set working directory to %s: %s", + exeDir.string().c_str(), ec.message().c_str()); + } +#endif + } else { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "SDL_GetBasePath() failed; asset lookups rely on current directory: %s", + SDL_GetError()); + } + + // Asset loader (creates SDL_Textures on the main thread) + assetLoader.init(renderer); + loadingManager = std::make_unique(&assetLoader); + + // Legacy image loader (used only as a fallback when AssetLoader misses) + textureLoader = std::make_unique( + loadedTasks, + currentLoadingFile, + currentLoadingMutex, + assetLoadErrors, + assetLoadErrorsMutex); + + // Load scores asynchronously but keep the worker alive until shutdown + scoreLoader = std::jthread([this]() { + scores.load(); + scoresLoadComplete.store(true, std::memory_order_release); + }); + + starfield.init(200, LOGICAL_W, LOGICAL_H); + starfield3D.init(LOGICAL_W, LOGICAL_H, 200); + spaceWarp.init(LOGICAL_W, LOGICAL_H, 420); + spaceWarp.setFlightMode(warpFlightMode); + warpAutoPilotEnabled = true; + spaceWarp.setAutoPilotEnabled(true); + + // Initialize line clearing effects + lineEffect.init(renderer); + + game = std::make_unique(startLevelSelection); + game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); + game->reset(startLevelSelection); + + coopGame = std::make_unique(startLevelSelection); + + // Define voice line banks for gameplay callbacks + singleSounds = {"well_played", "smooth_clear", "great_move"}; + doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; + tripleSounds = {"impressive", "triple_strike"}; + tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"}; + suppressLineVoiceForLevelUp = false; + + auto playVoiceCue = [this](int linesCleared) { + const std::vector* bank = nullptr; + switch (linesCleared) { + case 1: bank = &singleSounds; break; + case 2: bank = &doubleSounds; break; + case 3: bank = &tripleSounds; break; + default: + if (linesCleared >= 4) { + bank = &tetrisSounds; + } + break; + } + if (bank && !bank->empty()) { + SoundEffectManager::instance().playRandomSound(*bank, 1.0f); + } + }; + + game->setSoundCallback([this, playVoiceCue](int linesCleared) { + if (linesCleared <= 0) { + return; + } + + SoundEffectManager::instance().playSound("clear_line", 1.0f); + + if (!suppressLineVoiceForLevelUp) { + playVoiceCue(linesCleared); + } + suppressLineVoiceForLevelUp = false; + }); + + // Keep co-op line-clear SFX behavior identical to classic. + coopGame->setSoundCallback([this, playVoiceCue](int linesCleared) { + if (linesCleared <= 0) { + return; + } + + SoundEffectManager::instance().playSound("clear_line", 1.0f); + + if (!suppressLineVoiceForLevelUp) { + playVoiceCue(linesCleared); + } + suppressLineVoiceForLevelUp = false; + }); + + game->setLevelUpCallback([this](int /*newLevel*/) { + if (skipNextLevelUpJingle) { + skipNextLevelUpJingle = false; + } else { + SoundEffectManager::instance().playSound("new_level", 1.0f); + SoundEffectManager::instance().playSound("lets_go", 1.0f); + } + suppressLineVoiceForLevelUp = true; + }); + + // Mirror single-player level-up audio/visual behavior for Coop sessions + coopGame->setLevelUpCallback([this](int /*newLevel*/) { + 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(); + running = true; + isFullscreen = Settings::instance().isFullscreen(); + leftHeld = false; + rightHeld = false; + p1LeftHeld = p1RightHeld = p2LeftHeld = p2RightHeld = false; + moveTimerMs = 0; + p1MoveTimerMs = 0.0; + p2MoveTimerMs = 0.0; + DAS = 170.0; + ARR = 40.0; + logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H}; + logicalScale = 1.f; + lastMs = SDL_GetPerformanceCounter(); + + menuFadePhase = MenuFadePhase::None; + menuFadeClockMs = 0.0; + menuFadeAlpha = 0.0f; + MENU_PLAY_FADE_DURATION_MS = 450.0; + menuFadeTarget = AppState::Menu; + menuPlayCountdownArmed = false; + gameplayCountdownActive = false; + gameplayCountdownElapsed = 0.0; + gameplayCountdownIndex = 0; + GAMEPLAY_COUNTDOWN_STEP_MS = 400.0; + GAMEPLAY_COUNTDOWN_LABELS = { "3", "2", "1", "START" }; + gameplayBackgroundClockMs = 0.0; + + // Instantiate state manager + stateMgr = std::make_unique(state); + + // Prepare shared context for states + ctx = StateContext{}; + ctx.stateManager = stateMgr.get(); + ctx.game = game.get(); + ctx.coopGame = coopGame.get(); + ctx.scores = nullptr; + ctx.starfield = &starfield; + ctx.starfield3D = &starfield3D; + ctx.font = &font; + ctx.pixelFont = &pixelFont; + ctx.lineEffect = &lineEffect; + ctx.logoTex = logoTex; + ctx.logoSmallTex = logoSmallTex; + ctx.logoSmallW = logoSmallW; + ctx.logoSmallH = logoSmallH; + ctx.backgroundTex = nullptr; + ctx.asteroidsTex = asteroidsTex; + ctx.blocksTex = blocksTex; + ctx.scorePanelTex = scorePanelTex; + ctx.statisticsPanelTex = statisticsPanelTex; + ctx.nextPanelTex = nextPanelTex; + ctx.mainScreenTex = mainScreenTex; + ctx.mainScreenW = mainScreenW; + ctx.mainScreenH = mainScreenH; + ctx.musicEnabled = &musicEnabled; + ctx.coopVsAI = &coopVsAI; + ctx.startLevelSelection = &startLevelSelection; + ctx.hoveredButton = &hoveredButton; + ctx.showSettingsPopup = &showSettingsPopup; + ctx.showHelpOverlay = &showHelpOverlay; + ctx.showExitConfirmPopup = &showExitConfirmPopup; + 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) { + SDL_SetWindowFullscreen(window, enable ? SDL_WINDOW_FULLSCREEN : 0); + isFullscreen = enable; + }; + ctx.queryFullscreen = [this]() -> bool { + return (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; + }; + ctx.requestQuit = [this]() { + running = false; + }; + + auto beginStateFade = [this](AppState targetState, bool armGameplayCountdown) { + if (!ctx.stateManager) { + return; + } + if (state == targetState) { + return; + } + if (menuFadePhase != MenuFadePhase::None) { + return; + } + + menuFadePhase = MenuFadePhase::FadeOut; + menuFadeClockMs = 0.0; + menuFadeAlpha = 0.0f; + menuFadeTarget = targetState; + menuPlayCountdownArmed = armGameplayCountdown; + gameplayCountdownActive = false; + gameplayCountdownIndex = 0; + gameplayCountdownElapsed = 0.0; + + if (!armGameplayCountdown) { + if (game) { + game->setPaused(false); + } + } + }; + + auto startMenuPlayTransition = [this, beginStateFade]() { + if (!ctx.stateManager) { + return; + } + if (state != AppState::Menu) { + if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) { + coopAI.reset(); + } + state = AppState::Playing; + ctx.stateManager->setState(state); + return; + } + + if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) { + coopAI.reset(); + } + beginStateFade(AppState::Playing, true); + }; + ctx.startPlayTransition = startMenuPlayTransition; + + auto requestStateFade = [this, startMenuPlayTransition, beginStateFade](AppState targetState) { + if (!ctx.stateManager) { + return; + } + if (targetState == AppState::Playing) { + startMenuPlayTransition(); + return; + } + beginStateFade(targetState, false); + }; + ctx.requestFadeTransition = requestStateFade; + + ctx.startupFadeActive = &startupFadeActive; + ctx.startupFadeAlpha = &startupFadeAlpha; + + loadingState = std::make_unique(ctx); + videoState = std::make_unique(ctx); + menuState = std::make_unique(ctx); + optionsState = std::make_unique(ctx); + levelSelectorState = std::make_unique(ctx); + playingState = std::make_unique(ctx); + + stateMgr->registerHandler(AppState::Loading, [this](const SDL_Event& e){ loadingState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); + stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); }); + + stateMgr->registerHandler(AppState::Video, [this](const SDL_Event& e){ if (videoState) videoState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Video, [this]() { + if (!videoState) return; + const bool ok = videoState->begin(renderer, introVideoPath); + if (!ok) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Intro video unavailable; skipping to Menu"); + state = AppState::Menu; + stateMgr->setState(state); + return; + } + videoState->onEnter(); + }); + stateMgr->registerOnExit(AppState::Video, [this](){ if (videoState) videoState->onExit(); }); + + stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); + stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); + + stateMgr->registerHandler(AppState::Options, [this](const SDL_Event& e){ optionsState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Options, [this](){ optionsState->onEnter(); }); + stateMgr->registerOnExit(AppState::Options, [this](){ optionsState->onExit(); }); + + stateMgr->registerHandler(AppState::LevelSelector, [this](const SDL_Event& e){ levelSelectorState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::LevelSelector, [this](){ levelSelectorState->onEnter(); }); + stateMgr->registerOnExit(AppState::LevelSelector, [this](){ levelSelectorState->onExit(); }); + + stateMgr->registerHandler(AppState::Playing, [this](const SDL_Event& e){ playingState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Playing, [this](){ playingState->onEnter(); }); + stateMgr->registerOnExit(AppState::Playing, [this](){ playingState->onExit(); }); + + loadingState->onEnter(); + loadingStarted.store(true); + + return 0; +} + +void TetrisApp::Impl::runLoop() +{ + auto ensureScoresLoaded = [this]() { + if (scoreLoader.joinable()) { + scoreLoader.join(); + } + if (!ctx.scores) { + ctx.scores = &scores; + } + }; + + auto startMenuPlayTransition = [this]() { + if (ctx.startPlayTransition) { + ctx.startPlayTransition(); + } + }; + + auto requestStateFade = [this](AppState targetState) { + if (ctx.requestFadeTransition) { + ctx.requestFadeTransition(targetState); + } + }; + + 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)) { + ensureScoresLoaded(); + } + + int winW = 0, winH = 0; + SDL_GetWindowSize(window, &winW, &winH); + + logicalScale = std::min(winW / (float)LOGICAL_W, winH / (float)LOGICAL_H); + if (logicalScale <= 0) + logicalScale = 1.f; + + logicalVP.w = winW; + logicalVP.h = winH; + logicalVP.x = 0; + logicalVP.y = 0; + + SDL_Event e; + while (SDL_PollEvent(&e)) + { + if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) + running = false; + else { + const bool isUserInputEvent = + e.type == SDL_EVENT_KEY_DOWN || + e.type == SDL_EVENT_KEY_UP || + e.type == SDL_EVENT_TEXT_INPUT || + e.type == SDL_EVENT_MOUSE_BUTTON_DOWN || + e.type == SDL_EVENT_MOUSE_BUTTON_UP || + e.type == SDL_EVENT_MOUSE_MOTION; + + if (!(showHelpOverlay && isUserInputEvent)) { + stateMgr->handleEvent(e); + state = stateMgr->getState(); + } + + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + if (e.key.scancode == SDL_SCANCODE_M) + { + Audio::instance().toggleMute(); + musicEnabled = !musicEnabled; + Settings::instance().setMusicEnabled(musicEnabled); + } + if (e.key.scancode == SDL_SCANCODE_N) + { + Audio::instance().skipToNextTrack(); + if (!musicStarted && Audio::instance().getLoadedTrackCount() > 0) { + musicStarted = true; + musicEnabled = true; + Settings::instance().setMusicEnabled(true); + } + } + // K: Toggle sound effects (S is reserved for co-op movement) + if (e.key.scancode == SDL_SCANCODE_K) + { + SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled()); + Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); + } + const bool helpToggleKey = + (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu); + if (helpToggleKey) + { + showHelpOverlay = !showHelpOverlay; + if (state == AppState::Playing) { + if (showHelpOverlay) { + if (!game->isPaused()) { + game->setPaused(true); + helpOverlayPausedGame = true; + } else { + helpOverlayPausedGame = false; + } + } else if (helpOverlayPausedGame) { + game->setPaused(false); + helpOverlayPausedGame = false; + } + } else if (!showHelpOverlay) { + helpOverlayPausedGame = false; + } + } + if (e.key.scancode == SDL_SCANCODE_ESCAPE && showHelpOverlay) { + showHelpOverlay = false; + if (state == AppState::Playing && helpOverlayPausedGame) { + game->setPaused(false); + } + helpOverlayPausedGame = false; + } + if (e.key.key == SDLK_F11 || (e.key.key == SDLK_RETURN && (e.key.mod & SDL_KMOD_ALT))) + { + isFullscreen = !isFullscreen; + SDL_SetWindowFullscreen(window, isFullscreen ? SDL_WINDOW_FULLSCREEN : 0); + Settings::instance().setFullscreen(isFullscreen); + } + if (e.key.scancode == SDL_SCANCODE_F5) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::Forward; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: forward"); + } + if (e.key.scancode == SDL_SCANCODE_F6) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::BankLeft; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank left"); + } + if (e.key.scancode == SDL_SCANCODE_F7) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::BankRight; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: bank right"); + } + if (e.key.scancode == SDL_SCANCODE_F8) + { + warpAutoPilotEnabled = false; + warpFlightMode = SpaceWarpFlightMode::Reverse; + spaceWarp.setFlightMode(warpFlightMode); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp mode: reverse"); + } + if (e.key.scancode == SDL_SCANCODE_F9) + { + warpAutoPilotEnabled = true; + spaceWarp.setAutoPilotEnabled(true); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Space warp autopilot engaged"); + } + } + + if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) { + // Support single-player and coop two-name entry + if (game && game->getMode() == GameMode::Cooperate && coopGame) { + if (highScoreEntryIndex == 0) { + if (playerName.length() < 12) playerName += e.text.text; + } else { + if (player2Name.length() < 12) player2Name += e.text.text; + } + } else { + if (playerName.length() < 12) playerName += e.text.text; + } + } + + if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + if (isNewHighScore) { + if (game && game->getMode() == GameMode::Cooperate && coopGame) { + if (coopVsAI) { + // One-name entry flow (CPU is LEFT, human enters RIGHT name) + if (e.key.scancode == SDL_SCANCODE_BACKSPACE) { + if (!player2Name.empty()) player2Name.pop_back(); + } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { + if (player2Name.empty()) player2Name = "P2"; + std::string combined = std::string("CPU") + " & " + player2Name; + int leftScore = coopGame->score(CoopGame::PlayerSide::Left); + int rightScore = coopGame->score(CoopGame::PlayerSide::Right); + int combinedScore = leftScore + rightScore; + ensureScoresLoaded(); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); + Settings::instance().setPlayerName(player2Name); + isNewHighScore = false; + SDL_StopTextInput(window); + } + } else { + // Two-name entry flow + if (e.key.scancode == SDL_SCANCODE_BACKSPACE) { + if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back(); + else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back(); + } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { + if (highScoreEntryIndex == 0) { + if (playerName.empty()) playerName = "P1"; + highScoreEntryIndex = 1; // move to second name + } else { + if (player2Name.empty()) player2Name = "P2"; + // Submit combined name + std::string combined = playerName + " & " + player2Name; + int leftScore = coopGame->score(CoopGame::PlayerSide::Left); + int rightScore = coopGame->score(CoopGame::PlayerSide::Right); + int combinedScore = leftScore + rightScore; + ensureScoresLoaded(); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); + Settings::instance().setPlayerName(playerName); + isNewHighScore = false; + SDL_StopTextInput(window); + } + } + } + } else { + if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) { + playerName.pop_back(); + } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { + if (playerName.empty()) playerName = "PLAYER"; + ensureScoresLoaded(); + std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName, gt); + Settings::instance().setPlayerName(playerName); + isNewHighScore = false; + SDL_StopTextInput(window); + } + } + } 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 if (game->getMode() == GameMode::Cooperate) { + game->setMode(GameMode::Cooperate); + game->reset(startLevelSelection); + } else { + game->setMode(GameMode::Endless); + game->reset(startLevelSelection); + } + state = AppState::Playing; + stateMgr->setState(state); + } else if (e.key.scancode == SDL_SCANCODE_ESCAPE) { + state = AppState::Menu; + stateMgr->setState(state); + } + } + } + + if (!showHelpOverlay && e.type == SDL_EVENT_MOUSE_BUTTON_DOWN) + { + float mx = (float)e.button.x, my = (float)e.button.y; + if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h) + { + float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale; + if (state == AppState::Menu) + { + if (showSettingsPopup) { + showSettingsPopup = false; + } else { + ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; + + auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); + hoveredButton = menuInput.hoveredIndex; + + if (menuInput.activated) { + switch (*menuInput.activated) { + case ui::BottomMenuItem::Play: + if (game) game->setMode(GameMode::Endless); + startMenuPlayTransition(); + break; + case ui::BottomMenuItem::Cooperate: + if (menuState) { + menuState->showCoopSetupPanel(true); + } + 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: + requestStateFade(AppState::LevelSelector); + break; + case ui::BottomMenuItem::Options: + requestStateFade(AppState::Options); + break; + case ui::BottomMenuItem::Help: + if (menuState) menuState->showHelpPanel(true); + break; + case ui::BottomMenuItem::About: + if (menuState) menuState->showAboutPanel(true); + break; + case ui::BottomMenuItem::Exit: + showExitConfirmPopup = true; + exitPopupSelectedButton = 1; + break; + } + } + + SDL_FRect settingsBtn{SETTINGS_BTN_X, SETTINGS_BTN_Y, SETTINGS_BTN_W, SETTINGS_BTN_H}; + if (lx >= settingsBtn.x && lx <= settingsBtn.x + settingsBtn.w && ly >= settingsBtn.y && ly <= settingsBtn.y + settingsBtn.h) + { + showSettingsPopup = true; + } + } + } + else if (state == AppState::LevelSelect) + startLevelSelection = (startLevelSelection + 1) % 20; + else if (state == AppState::GameOver) { + state = AppState::Menu; + stateMgr->setState(state); + } + else if (state == AppState::Playing && showExitConfirmPopup) { + float lx2 = (mx - logicalVP.x) / logicalScale; + float ly2 = (my - logicalVP.y) / logicalScale; + float contentW = LOGICAL_W * logicalScale; + float contentH = LOGICAL_H * logicalScale; + float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; + float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; + float localX = lx2 - contentOffsetX; + float localY = ly2 - contentOffsetY; + + float popupW = 400, popupH = 200; + float popupX = (LOGICAL_W - popupW) / 2.0f; + float popupY = (LOGICAL_H - popupH) / 2.0f; + float btnW = 120.0f, btnH = 40.0f; + float yesX = popupX + popupW * 0.25f - btnW / 2.0f; + float noX = popupX + popupW * 0.75f - btnW / 2.0f; + float btnY = popupY + popupH - btnH - 20.0f; + + if (localX >= popupX && localX <= popupX + popupW && localY >= popupY && localY <= popupY + popupH) { + if (localX >= yesX && localX <= yesX + btnW && localY >= btnY && localY <= btnY + btnH) { + showExitConfirmPopup = false; + game->reset(startLevelSelection); + state = AppState::Menu; + stateMgr->setState(state); + } else if (localX >= noX && localX <= noX + btnW && localY >= btnY && localY <= btnY + btnH) { + showExitConfirmPopup = false; + game->setPaused(false); + } + } else { + showExitConfirmPopup = false; + game->setPaused(false); + } + } + else if (state == AppState::Menu && showExitConfirmPopup) { + float contentW = LOGICAL_W * logicalScale; + float contentH = LOGICAL_H * logicalScale; + float contentOffsetX = (winW - contentW) * 0.5f / logicalScale; + float contentOffsetY = (winH - contentH) * 0.5f / logicalScale; + float popupW = 420.0f; + float popupH = 230.0f; + float popupX = (LOGICAL_W - popupW) * 0.5f + contentOffsetX; + float popupY = (LOGICAL_H - popupH) * 0.5f + contentOffsetY; + float btnW = 140.0f; + float btnH = 50.0f; + float yesX = popupX + popupW * 0.3f - btnW / 2.0f; + float noX = popupX + popupW * 0.7f - btnW / 2.0f; + float btnY = popupY + popupH - btnH - 30.0f; + bool insidePopup = lx >= popupX && lx <= popupX + popupW && ly >= popupY && ly <= popupY + popupH; + if (insidePopup) { + if (lx >= yesX && lx <= yesX + btnW && ly >= btnY && ly <= btnY + btnH) { + showExitConfirmPopup = false; + running = false; + } else if (lx >= noX && lx <= noX + btnW && ly >= btnY && ly <= btnY + btnH) { + showExitConfirmPopup = false; + } + } else { + showExitConfirmPopup = false; + } + } + } + } + else if (!showHelpOverlay && e.type == SDL_EVENT_MOUSE_MOTION) + { + float mx = (float)e.motion.x, my = (float)e.motion.y; + if (mx >= logicalVP.x && my >= logicalVP.y && mx <= logicalVP.x + logicalVP.w && my <= logicalVP.y + logicalVP.h) + { + float lx = (mx - logicalVP.x) / logicalScale, ly = (my - logicalVP.y) / logicalScale; + if (state == AppState::Menu && !showSettingsPopup) + { + ui::MenuLayoutParams params{ LOGICAL_W, LOGICAL_H, winW, winH, logicalScale }; + auto menuInput = ui::handleBottomMenuInput(params, e, lx, ly, hoveredButton, true); + hoveredButton = menuInput.hoveredIndex; + } + } + } + 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); + } + } + } + } + } + + // State transitions can be triggered from render/update (e.g. menu network handshake). + // Keep our cached `state` in sync every frame, not only when events occur. + state = stateMgr->getState(); + + Uint64 now = SDL_GetPerformanceCounter(); + double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); + lastMs = now; + if (frameMs > 100.0) frameMs = 100.0; + gameplayBackgroundClockMs += frameMs; + + if (startupFadeActive) { + if (startupFadeClockMs <= 0.0) { + startupFadeClockMs = STARTUP_FADE_IN_MS; + startupFadeAlpha = 1.0f; + } + startupFadeClockMs -= frameMs; + if (startupFadeClockMs <= 0.0) { + startupFadeClockMs = 0.0; + startupFadeAlpha = 0.0f; + startupFadeActive = false; + } else { + startupFadeAlpha = float(std::clamp(startupFadeClockMs / STARTUP_FADE_IN_MS, 0.0, 1.0)); + } + } + + 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]; + bool down = state == AppState::Playing && ks[SDL_SCANCODE_DOWN]; + + if (state == AppState::Playing) + game->setSoftDropping(down && !game->isPaused()); + else + game->setSoftDropping(false); + + int moveDir = 0; + if (left && !right) + moveDir = -1; + else if (right && !left) + moveDir = +1; + + if (moveDir != 0 && !game->isPaused()) + { + if ((moveDir == -1 && leftHeld == false) || (moveDir == +1 && rightHeld == false)) + { + game->move(moveDir); + moveTimerMs = DAS; + } + else + { + moveTimerMs -= frameMs; + if (moveTimerMs <= 0) + { + game->move(moveDir); + moveTimerMs += ARR; + } + } + } + else + moveTimerMs = 0; + leftHeld = left; + rightHeld = right; + if (down && !game->isPaused()) + game->softDropBoost(frameMs); + + if (musicLoadingStarted && !musicLoaded) { + currentTrackLoading = Audio::instance().getLoadedTrackCount(); + if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) { + Audio::instance().shuffle(); + musicLoaded = true; + } + } + + if (state == AppState::Playing) + { + const bool coopActive = game && game->getMode() == GameMode::Cooperate && coopGame; + + if (coopActive) { + // Coop DAS/ARR handling (per-side) + const bool* ks = SDL_GetKeyboardState(nullptr); + + auto handleSide = [&](CoopGame::PlayerSide side, + bool leftHeldPrev, + bool rightHeldPrev, + double& timer, + SDL_Scancode leftKey, + SDL_Scancode rightKey, + SDL_Scancode downKey) { + bool left = ks[leftKey]; + bool right = ks[rightKey]; + bool down = ks[downKey]; + + coopGame->setSoftDropping(side, down); + + int moveDir = 0; + if (left && !right) moveDir = -1; + else if (right && !left) moveDir = +1; + + if (moveDir != 0) { + if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) { + coopGame->move(side, moveDir); + timer = DAS; + } else { + timer -= frameMs; + if (timer <= 0) { + coopGame->move(side, moveDir); + timer += ARR; + } + } + } else { + timer = 0.0; + } + }; + + if (game->isPaused()) { + // While paused, suppress all continuous input changes so pieces don't drift. + if (ctx.coopNetEnabled && ctx.coopNetSession) { + ctx.coopNetSession->poll(0); + ctx.coopNetStalled = false; + } + coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false); + coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false); + p1MoveTimerMs = 0.0; + p2MoveTimerMs = 0.0; + p1LeftHeld = false; + p1RightHeld = false; + p2LeftHeld = false; + p2RightHeld = false; + } else { + const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession; + + // If we just entered network co-op, reset per-session fixed-tick bookkeeping. + if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) { + coopNetAccMs = 0.0; + coopNetCachedTick = 0xFFFFFFFFu; + coopNetCachedButtons = 0; + coopNetLastHashSentTick = 0xFFFFFFFFu; + ctx.coopNetStalled = false; + } + + // Define canonical key mappings for left and right players + const SDL_Scancode leftLeftKey = SDL_SCANCODE_A; + const SDL_Scancode leftRightKey = SDL_SCANCODE_D; + const SDL_Scancode leftDownKey = SDL_SCANCODE_S; + + const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT; + const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT; + const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN; + + if (coopNetActive) { + // Network co-op: fixed tick lockstep. + // Use a fixed dt so both peers simulate identically. + static constexpr double FIXED_DT_MS = 1000.0 / 60.0; + static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s + + ctx.coopNetSession->poll(0); + + // If the connection drops during gameplay, abort back to menu. + if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected || + ctx.coopNetSession->state() == NetSession::ConnState::Error) { + const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty()) + ? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError()) + : std::string("NET DISCONNECTED"); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str()); + + ctx.coopNetUiStatusText = reason; + ctx.coopNetUiStatusRemainingMs = 6000.0; + ctx.coopNetEnabled = false; + ctx.coopNetStalled = false; + ctx.coopNetDesyncDetected = false; + ctx.coopNetTick = 0; + ctx.coopNetPendingButtons = 0; + if (ctx.coopNetSession) { + ctx.coopNetSession->shutdown(); + ctx.coopNetSession.reset(); + } + + // Ensure we don't remain paused due to a previous net stall/desync. + if (game) { + game->setPaused(false); + } + state = AppState::Menu; + stateMgr->setState(state); + continue; + } + + coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0); + + auto buildLocalButtons = [&]() -> uint8_t { + uint8_t b = 0; + if (ctx.coopNetLocalIsLeft) { + if (ks[leftLeftKey]) b |= coopnet::MoveLeft; + if (ks[leftRightKey]) b |= coopnet::MoveRight; + if (ks[leftDownKey]) b |= coopnet::SoftDrop; + } else { + if (ks[rightLeftKey]) b |= coopnet::MoveLeft; + if (ks[rightRightKey]) b |= coopnet::MoveRight; + if (ks[rightDownKey]) b |= coopnet::SoftDrop; + } + b |= ctx.coopNetPendingButtons; + ctx.coopNetPendingButtons = 0; + return b; + }; + + auto applyButtonsForSide = [&](CoopGame::PlayerSide side, + uint8_t buttons, + bool& leftHeldPrev, + bool& rightHeldPrev, + double& timer) { + const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft); + const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight); + const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop); + + coopGame->setSoftDropping(side, downHeldNow); + + int moveDir = 0; + if (leftHeldNow && !rightHeldNow) moveDir = -1; + else if (rightHeldNow && !leftHeldNow) moveDir = +1; + + if (moveDir != 0) { + if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) { + coopGame->move(side, moveDir); + timer = DAS; + } else { + timer -= FIXED_DT_MS; + if (timer <= 0.0) { + coopGame->move(side, moveDir); + timer += ARR; + } + } + } else { + timer = 0.0; + } + + if (coopnet::has(buttons, coopnet::RotCW)) { + coopGame->rotate(side, +1); + } + if (coopnet::has(buttons, coopnet::RotCCW)) { + coopGame->rotate(side, -1); + } + if (coopnet::has(buttons, coopnet::HardDrop)) { + SoundEffectManager::instance().playSound("hard_drop", 0.7f); + coopGame->hardDrop(side); + } + if (coopnet::has(buttons, coopnet::Hold)) { + coopGame->holdCurrent(side); + } + + leftHeldPrev = leftHeldNow; + rightHeldPrev = rightHeldNow; + }; + + const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT"; + + int safetySteps = 0; + bool advancedTick = false; + ctx.coopNetStalled = false; + while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) { + const uint32_t tick = ctx.coopNetTick; + + if (coopNetCachedTick != tick) { + coopNetCachedTick = tick; + coopNetCachedButtons = buildLocalButtons(); + if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[NET COOP][%s] sendLocalInput failed (tick=%u)", + roleStr, + tick); + } + } + + auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick); + if (!remoteButtonsOpt.has_value()) { + if (!ctx.coopNetStalled) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "[NET COOP][%s] STALL begin waitingForTick=%u", + roleStr, + tick); + } + ctx.coopNetStalled = true; + break; // lockstep stall + } + + const uint8_t remoteButtons = remoteButtonsOpt.value(); + const bool localIsLeft = ctx.coopNetLocalIsLeft; + + if (localIsLeft) { + applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs); + applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs); + } else { + applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs); + applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs); + } + + coopGame->tickGravity(FIXED_DT_MS); + coopGame->updateVisualEffects(FIXED_DT_MS); + + if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) { + coopNetLastHashSentTick = tick; + const uint64_t hash = coopGame->computeStateHash(); + if (!ctx.coopNetSession->sendStateHash(tick, hash)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)", + roleStr, + tick, + (unsigned long long)hash); + } + auto rh = ctx.coopNetSession->takeRemoteHash(tick); + if (rh.has_value() && rh.value() != hash) { + ctx.coopNetDesyncDetected = true; + ctx.coopNetUiStatusText = "NET DESYNC"; + ctx.coopNetUiStatusRemainingMs = 8000.0; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX", + roleStr, + tick, + (unsigned long long)hash, + (unsigned long long)rh.value()); + game->setPaused(true); + } + } + + ctx.coopNetTick++; + advancedTick = true; + coopNetAccMs -= FIXED_DT_MS; + } + + if (advancedTick) { + if (ctx.coopNetStalled) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "[NET COOP][%s] STALL end atTick=%u", + roleStr, + ctx.coopNetTick); + } + ctx.coopNetStalled = false; + } + } else if (!coopVsAI) { + // Standard two-player: left uses WASD, right uses arrow keys + handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey); + handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); + + p1LeftHeld = ks[leftLeftKey]; + p1RightHeld = ks[leftRightKey]; + p2LeftHeld = ks[rightLeftKey]; + p2RightHeld = ks[rightRightKey]; + } else { + // Coop vs CPU: AI controls LEFT, human controls RIGHT (arrow keys). + // Handle continuous input for the human on the right side. + handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); + + // Mirror the human soft-drop to the AI-controlled left board so both fall together. + const bool pRightSoftDrop = ks[rightDownKey]; + coopGame->setSoftDropping(CoopGame::PlayerSide::Left, pRightSoftDrop); + + // Reset left continuous timers/held flags (AI handles movement) + p1MoveTimerMs = 0.0; + p1LeftHeld = false; + p1RightHeld = false; + + // Update AI for the left side + coopAI.update(*coopGame, CoopGame::PlayerSide::Left, frameMs); + // Update human-held flags for right-side controls so DAS/ARR state is tracked + p2LeftHeld = ks[rightLeftKey]; + p2RightHeld = ks[rightRightKey]; + } + + if (!coopNetActive) { + coopGame->tickGravity(frameMs); + coopGame->updateVisualEffects(frameMs); + } + } + + if (coopGame->isGameOver()) { + // Compute combined coop stats for Game Over + int leftScore = coopGame->score(CoopGame::PlayerSide::Left); + int rightScore = coopGame->score(CoopGame::PlayerSide::Right); + int combinedScore = leftScore + rightScore; + if (combinedScore > 0) { + isNewHighScore = true; + if (coopVsAI) { + // AI is left, prompt human (right) for name + playerName = "CPU"; + player2Name.clear(); + highScoreEntryIndex = 1; // enter P2 (human) + } else { + playerName.clear(); + player2Name.clear(); + highScoreEntryIndex = 0; + } + SDL_StartTextInput(window); + } else { + isNewHighScore = false; + ensureScoresLoaded(); + // When AI is present, label should indicate CPU left and human right + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), coopVsAI ? "CPU & P2" : "P1 & P2", "cooperate"); + } + state = AppState::GameOver; + stateMgr->setState(state); + + if (ctx.coopNetSession) { + ctx.coopNetSession->shutdown(); + ctx.coopNetSession.reset(); + } + ctx.coopNetEnabled = false; + } + + } else { + if (!game->isPaused()) { + game->tickGravity(frameMs); + game->updateElapsedTime(); + + if (lineEffect.isActive()) { + if (lineEffect.update(frameMs / 1000.0f)) { + game->clearCompletedLines(); + } + } + } + if (game->isGameOver()) + { + if (game->score() > 0) { + isNewHighScore = true; + playerName.clear(); + SDL_StartTextInput(window); + } else { + isNewHighScore = false; + ensureScoresLoaded(); + { + std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt); + } + } + state = AppState::GameOver; + stateMgr->setState(state); + } + } + } + else if (state == AppState::Loading) + { + static int queuedTextureCount = 0; + if (loadingStarted.load() && !loadingComplete.load()) { + static bool queuedTextures = false; + static std::vector queuedPaths; + if (!queuedTextures) { + queuedTextures = true; + constexpr int baseTasks = 25; + totalLoadingTasks.store(baseTasks); + loadedTasks.store(0); + { + std::lock_guard lk(assetLoadErrorsMutex); + assetLoadErrors.clear(); + } + { + std::lock_guard lk(currentLoadingMutex); + currentLoadingFile.clear(); + } + + Audio::instance().init(); + totalTracks = 0; + for (int i = 1; i <= 100; ++i) { + char base[128]; + std::snprintf(base, sizeof(base), "assets/music/music%03d", i); + std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" }); + if (path.empty()) break; + Audio::instance().addTrackAsync(path); + totalTracks++; + } + totalLoadingTasks.store(baseTasks + totalTracks); + if (totalTracks > 0) { + Audio::instance().startBackgroundLoading(); + musicLoadingStarted = true; + } else { + musicLoaded = true; + } + + pixelFont.init(AssetPath::resolveWithBase(Assets::FONT_ORBITRON), 22); + loadedTasks.fetch_add(1); + font.init(AssetPath::resolveWithBase(Assets::FONT_EXO2), 20); + loadedTasks.fetch_add(1); + + queuedPaths = { + Assets::LOGO, + Assets::LOGO, + Assets::MAIN_SCREEN, + Assets::BLOCKS_SPRITE, + Assets::PANEL_SCORE, + Assets::PANEL_STATS, + Assets::NEXT_PANEL, + Assets::HOLD_PANEL, + Assets::ASTEROID_SPRITE + }; + for (auto &p : queuedPaths) { + loadingManager->queueTexture(p); + } + queuedTextureCount = static_cast(queuedPaths.size()); + + 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","asteroid_destroy","challenge_clear"}; + for (const auto &id : audioIds) { + 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; + } + std::string resolved = AssetPath::resolveWithExtensions(basePath, { ".wav", ".mp3" }); + if (!resolved.empty()) { + SoundEffectManager::instance().loadSound(id, resolved); + } + loadedTasks.fetch_add(1); + { + std::lock_guard lk(currentLoadingMutex); + currentLoadingFile.clear(); + } + } + } + + bool texturesDone = loadingManager->update(); + if (texturesDone) { + logoTex = assetLoader.getTexture(Assets::LOGO); + 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); + holdPanelTex = assetLoader.getTexture(Assets::HOLD_PANEL); + + auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) { + if (!tex) return; + if (outW > 0 && outH > 0) return; + float w = 0.0f, h = 0.0f; + if (SDL_GetTextureSize(tex, &w, &h)) { + outW = static_cast(std::lround(w)); + outH = static_cast(std::lround(h)); + } + }; + + ensureTextureSize(logoSmallTex, logoSmallW, logoSmallH); + ensureTextureSize(mainScreenTex, mainScreenW, mainScreenH); + + auto legacyLoad = [&](const std::string& p, SDL_Texture*& outTex, int* outW = nullptr, int* outH = nullptr) { + if (!outTex) { + SDL_Texture* loaded = textureLoader->loadFromImage(renderer, p, outW, outH); + if (loaded) { + outTex = loaded; + assetLoader.adoptTexture(p, loaded); + } + } + }; + + legacyLoad(Assets::LOGO, logoTex); + 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); + legacyLoad(Assets::HOLD_PANEL, holdPanelTex); + + if (!blocksTex) { + blocksTex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, 630, 90); + SDL_SetRenderTarget(renderer, blocksTex); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + for (int i = 0; i < PIECE_COUNT; ++i) { + SDL_Color c = COLORS[i + 1]; + SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); + SDL_FRect rect{(float)(i * 90), 0, 90, 90}; + SDL_RenderFillRect(renderer, &rect); + } + SDL_SetRenderTarget(renderer, nullptr); + + // Ensure the generated fallback texture is cleaned up with other assets. + assetLoader.adoptTexture(Assets::BLOCKS_SPRITE, blocksTex); + } + + if (musicLoaded) { + loadingComplete.store(true); + } + } + } + + const int totalTasks = totalLoadingTasks.load(std::memory_order_acquire); + const int musicDone = std::min(totalTracks, currentTrackLoading); + int doneTasks = loadedTasks.load(std::memory_order_acquire) + musicDone; + if (queuedTextureCount > 0) { + float texProg = loadingManager->getProgress(); + int texDone = static_cast(std::floor(texProg * queuedTextureCount + 0.5f)); + if (texDone > queuedTextureCount) texDone = queuedTextureCount; + doneTasks += texDone; + } + if (doneTasks > totalTasks) doneTasks = totalTasks; + if (totalTasks > 0) { + loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks)); + if (loadingProgress >= 1.0 && musicLoaded) { + startupFadeActive = false; + startupFadeAlpha = 0.0f; + startupFadeClockMs = 0.0; + + if (std::filesystem::exists(introVideoPath)) { + state = AppState::Video; + } else { + state = AppState::Menu; + } + stateMgr->setState(state); + } + } else { + double assetProgress = 0.2; + double musicProgress = 0.0; + if (totalTracks > 0) { + musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7); + } else { + if (Audio::instance().isLoadingComplete()) { + musicProgress = 0.7; + } else if (Audio::instance().getLoadedTrackCount() > 0) { + musicProgress = 0.35; + } else { + Uint32 elapsedMs = SDL_GetTicks() - static_cast(loadStart); + if (elapsedMs > 1500) { + musicProgress = 0.7; + musicLoaded = true; + } else { + musicProgress = 0.0; + } + } + } + double timeProgress = std::min(0.1, (now - loadStart) / 500.0); + loadingProgress = std::min(1.0, assetProgress + musicProgress + timeProgress); + if (loadingProgress > 0.99) loadingProgress = 1.0; + if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0; + if (loadingProgress >= 1.0 && musicLoaded) { + startupFadeActive = false; + startupFadeAlpha = 0.0f; + startupFadeClockMs = 0.0; + + if (std::filesystem::exists(introVideoPath)) { + state = AppState::Video; + } else { + state = AppState::Menu; + } + stateMgr->setState(state); + } + } + } + + if (state == AppState::Menu || state == AppState::Playing) + { + if (!musicStarted && musicLoaded) + { + static bool menuTrackLoaded = false; + if (!menuTrackLoaded) { + if (menuTrackLoader.joinable()) { + menuTrackLoader.join(); + } + menuTrackLoader = std::jthread([]() { + std::string menuTrack = AssetPath::resolveWithExtensions("assets/music/Every Block You Take", { ".mp3" }); + if (!menuTrack.empty()) { + Audio::instance().setMenuTrack(menuTrack); + } else { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Menu track not found (Every Block You Take)"); + } + }); + menuTrackLoaded = true; + } + + if (state == AppState::Menu) { + Audio::instance().playMenuMusic(); + } else { + Audio::instance().playGameMusic(); + } + musicStarted = true; + } + } + + static AppState previousState = AppState::Loading; + if (state != previousState && musicStarted) { + if (state == AppState::Menu && previousState == AppState::Playing) { + Audio::instance().playMenuMusic(); + } else if (state == AppState::Playing && previousState == AppState::Menu) { + Audio::instance().playGameMusic(); + } + } + previousState = state; + + if (state == AppState::Loading) { + starfield3D.update(float(frameMs / 1000.0f)); + starfield3D.resize(winW, winH); + } else { + starfield.update(float(frameMs / 1000.0f), logicalVP.x * 2 + logicalVP.w, logicalVP.y * 2 + logicalVP.h); + } + + if (state == AppState::Menu) { + spaceWarp.resize(winW, winH); + spaceWarp.update(float(frameMs / 1000.0f)); + } + + levelBackgrounds.update(float(frameMs)); + + if (state == AppState::Menu) { + logoAnimCounter += frameMs * 0.0008; + } + + switch (stateMgr->getState()) { + case AppState::Loading: + loadingState->update(frameMs); + break; + case AppState::Video: + if (videoState) videoState->update(frameMs); + break; + case AppState::Menu: + menuState->update(frameMs); + break; + case AppState::Options: + optionsState->update(frameMs); + break; + case AppState::LevelSelector: + levelSelectorState->update(frameMs); + break; + case AppState::Playing: + playingState->update(frameMs); + break; + default: + 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; + ctx.holdPanelTex = holdPanelTex; + ctx.mainScreenTex = mainScreenTex; + ctx.mainScreenW = mainScreenW; + ctx.mainScreenH = mainScreenH; + + if (menuFadePhase == MenuFadePhase::FadeOut) { + menuFadeClockMs += frameMs; + menuFadeAlpha = std::min(1.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS)); + if (menuFadeClockMs >= MENU_PLAY_FADE_DURATION_MS) { + if (state != menuFadeTarget) { + state = menuFadeTarget; + stateMgr->setState(state); + } + + 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; + gameplayCountdownElapsed = 0.0; + game->setPaused(true); + } else { + menuPlayCountdownArmed = false; + gameplayCountdownActive = false; + gameplayCountdownIndex = 0; + gameplayCountdownElapsed = 0.0; + challengeCountdownWaitingForSpace = false; + game->setPaused(false); + } + menuFadePhase = MenuFadePhase::FadeIn; + menuFadeClockMs = MENU_PLAY_FADE_DURATION_MS; + menuFadeAlpha = 1.0f; + } + } else if (menuFadePhase == MenuFadePhase::FadeIn) { + menuFadeClockMs -= frameMs; + menuFadeAlpha = std::max(0.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS)); + if (menuFadeClockMs <= 0.0) { + menuFadePhase = MenuFadePhase::None; + menuFadeClockMs = 0.0; + menuFadeAlpha = 0.0f; + } + } + + 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; + gameplayCountdownIndex = 0; + game->setPaused(true); + } + + if (gameplayCountdownActive && state == AppState::Playing) { + 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); + } + } + } + } + + if (state != AppState::Playing && gameplayCountdownActive) { + gameplayCountdownActive = false; + 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); + + if (state == AppState::Playing) { + int bgLevel = std::clamp(game->level(), 0, 32); + levelBackgrounds.queueLevelBackground(renderer, bgLevel); + levelBackgrounds.render(renderer, winW, winH, static_cast(gameplayBackgroundClockMs)); + } else if (state == AppState::Loading) { + starfield3D.draw(renderer); + } else if (state == AppState::Menu) { + spaceWarp.draw(renderer, 1.0f); + } else if (state == AppState::LevelSelector || state == AppState::Options) { + // No background texture + } else { + starfield.draw(renderer); + } + + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + + switch (state) + { + case AppState::Loading: + { + float contentScale = logicalScale; + float contentW = LOGICAL_W * contentScale; + float contentH = LOGICAL_H * contentScale; + float contentOffsetX = (winW - contentW) * 0.5f / contentScale; + float contentOffsetY = (winH - contentH) * 0.5f / contentScale; + + auto drawRect = [&](float x, float y, float w, float h, SDL_Color c) + { RenderPrimitives::fillRect(renderer, x + contentOffsetX, y + contentOffsetY, w, h, c); }; + + const bool isLimitedHeight = LOGICAL_H < 450; + const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0; + const float loadingTextHeight = 20; + const float barHeight = 20; + const float barPaddingVertical = isLimitedHeight ? 15 : 35; + const float percentTextHeight = 24; + const float spacingBetweenElements = isLimitedHeight ? 5 : 15; + + const float totalContentHeight = logoHeight + + (logoHeight > 0 ? spacingBetweenElements : 0) + + loadingTextHeight + + barPaddingVertical + + barHeight + + spacingBetweenElements + + percentTextHeight; + + float currentY = (LOGICAL_H - totalContentHeight) / 2.0f; + + if (logoTex) + { + const int lw = 872, lh = 273; + const float maxLogoWidth = std::min(LOGICAL_W * 0.9f, 600.0f); + const float availableHeight = isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f; + const float availableWidth = maxLogoWidth; + + const float scaleFactorWidth = availableWidth / static_cast(lw); + const float scaleFactorHeight = availableHeight / static_cast(lh); + const float scaleFactor = std::min(scaleFactorWidth, scaleFactorHeight); + + const float displayWidth = lw * scaleFactor; + const float displayHeight = lh * scaleFactor; + const float logoX = (LOGICAL_W - displayWidth) / 2.0f; + + SDL_FRect dst{logoX + contentOffsetX, currentY + contentOffsetY, displayWidth, displayHeight}; + SDL_RenderTexture(renderer, logoTex, nullptr, &dst); + + currentY += displayHeight + spacingBetweenElements; + } + + const char* loadingText = "LOADING"; + float textWidth = strlen(loadingText) * 12.0f; + float textX = (LOGICAL_W - textWidth) / 2.0f; + pixelFont.draw(renderer, textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255, 204, 0, 255}); + + currentY += loadingTextHeight + barPaddingVertical; + + const int barW = 400, barH = 20; + const int bx = (LOGICAL_W - barW) / 2; + + drawRect(bx - 3, currentY - 3, barW + 6, barH + 6, {68, 68, 80, 255}); + drawRect(bx, currentY, barW, barH, {34, 34, 34, 255}); + drawRect(bx, currentY, int(barW * loadingProgress), barH, {255, 204, 0, 255}); + + currentY += barH + spacingBetweenElements; + + int percentage = int(loadingProgress * 100); + char percentText[16]; + std::snprintf(percentText, sizeof(percentText), "%d%%", percentage); + + float percentWidth = strlen(percentText) * 12.0f; + float percentX = (LOGICAL_W - percentWidth) / 2.0f; + pixelFont.draw(renderer, percentX + contentOffsetX, currentY + contentOffsetY, percentText, 1.5f, {255, 204, 0, 255}); + + { + std::lock_guard lk(assetLoadErrorsMutex); + const int maxShow = 5; + int count = static_cast(assetLoadErrors.size()); + if (count > 0) { + int start = std::max(0, count - maxShow); + float errY = currentY + spacingBetweenElements + 8.0f; + + std::string latest = assetLoadErrors.back(); + std::string shortTitle = "SpaceTris - Missing assets"; + if (!latest.empty()) { + std::string trimmed = latest; + if (trimmed.size() > 48) trimmed = trimmed.substr(0, 45) + "..."; + shortTitle += ": "; + shortTitle += trimmed; + } + SDL_SetWindowTitle(window, shortTitle.c_str()); + + FILE* tf = fopen("tetris_trace.log", "a"); + if (tf) { + fprintf(tf, "Loading error: %s\n", assetLoadErrors.back().c_str()); + fclose(tf); + } + + for (int i = start; i < count; ++i) { + const std::string& msg = assetLoadErrors[i]; + std::string display = msg; + if (display.size() > 80) display = display.substr(0, 77) + "..."; + pixelFont.draw(renderer, 80 + contentOffsetX, errY + contentOffsetY, display.c_str(), 0.85f, {255, 100, 100, 255}); + errY += 20.0f; + } + } + } + + if (Settings::instance().isDebugEnabled()) { + std::string cur; + { + std::lock_guard lk(currentLoadingMutex); + cur = currentLoadingFile; + } + char buf[128]; + int loaded = loadedTasks.load(); + int total = totalLoadingTasks.load(); + std::snprintf(buf, sizeof(buf), "Loaded: %d / %d", loaded, total); + float debugX = 20.0f + contentOffsetX; + float debugY = LOGICAL_H - 48.0f + contentOffsetY; + pixelFont.draw(renderer, debugX, debugY, buf, 0.9f, SDL_Color{200,200,200,255}); + if (!cur.empty()) { + std::string display = "Loading: "; + display += cur; + if (display.size() > 80) display = display.substr(0,77) + "..."; + pixelFont.draw(renderer, debugX, debugY + 18.0f, display.c_str(), 0.85f, SDL_Color{200,180,120,255}); + } + } + } + break; + case AppState::Video: + if (videoState) { + videoState->render(renderer, logicalScale, logicalVP); + } + break; + case AppState::Menu: + if (!mainScreenTex) { + mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); + } + if (!mainScreenTex) { + SDL_Texture* loaded = textureLoader->loadFromImage(renderer, Assets::MAIN_SCREEN, &mainScreenW, &mainScreenH); + if (loaded) { + assetLoader.adoptTexture(Assets::MAIN_SCREEN, loaded); + mainScreenTex = loaded; + } + } + if (menuState) { + menuState->drawMainButtonNormally = false; + menuState->render(renderer, logicalScale, logicalVP); + } + if (mainScreenTex) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.f, 1.f); + float texW = mainScreenW > 0 ? static_cast(mainScreenW) : 0.0f; + float texH = mainScreenH > 0 ? static_cast(mainScreenH) : 0.0f; + if (texW <= 0.0f || texH <= 0.0f) { + float iwf = 0.0f, ihf = 0.0f; + if (!SDL_GetTextureSize(mainScreenTex, &iwf, &ihf)) { + iwf = ihf = 0.0f; + } + texW = iwf; + texH = ihf; + } + if (texW > 0.0f && texH > 0.0f) { + const float drawH = static_cast(winH); + const float scale = drawH / texH; + const float drawW = texW * scale; + SDL_FRect dst{ + (winW - drawW) * 0.5f, + 0.0f, + drawW, + drawH + }; + SDL_SetTextureBlendMode(mainScreenTex, SDL_BLENDMODE_BLEND); + SDL_SetTextureScaleMode(mainScreenTex, SDL_SCALEMODE_LINEAR); + SDL_RenderTexture(renderer, mainScreenTex, nullptr, &dst); + } + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + } + if (menuState) { + menuState->renderMainButtonTop(renderer, logicalScale, logicalVP); + } + break; + case AppState::Options: + optionsState->render(renderer, logicalScale, logicalVP); + break; + case AppState::LevelSelector: + levelSelectorState->render(renderer, logicalScale, logicalVP); + break; + case AppState::LevelSelect: + { + const std::string title = "SELECT LEVEL"; + int tW = 0, tH = 0; + font.measure(title, 2.5f, tW, tH); + float titleX = (LOGICAL_W - (float)tW) / 2.0f; + font.draw(renderer, titleX, 80, title, 2.5f, SDL_Color{255, 220, 0, 255}); + + char buf[64]; + std::snprintf(buf, sizeof(buf), "LEVEL: %d", startLevelSelection); + font.draw(renderer, LOGICAL_W * 0.5f - 80, 180, buf, 2.0f, SDL_Color{200, 240, 255, 255}); + font.draw(renderer, LOGICAL_W * 0.5f - 180, 260, "ARROWS CHANGE ENTER=OK ESC=BACK", 1.2f, SDL_Color{200, 200, 220, 255}); + } + break; + case AppState::Playing: + playingState->render(renderer, logicalScale, logicalVP); + break; + case AppState::GameOver: + GameRenderer::renderPlayingState( + renderer, + game.get(), + &pixelFont, + &lineEffect, + blocksTex, + asteroidsTex, + ctx.statisticsPanelTex, + scorePanelTex, + nextPanelTex, + holdPanelTex, + false, + (float)LOGICAL_W, + (float)LOGICAL_H, + logicalScale, + (float)winW, + (float)winH + ); + + { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); + SDL_FRect fullWin{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &fullWin); + + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + + float contentScale = logicalScale; + float contentW = LOGICAL_W * contentScale; + float contentH = LOGICAL_H * contentScale; + float contentOffsetX = (winW - contentW) * 0.5f / contentScale; + float contentOffsetY = (winH - contentH) * 0.5f / contentScale; + + float boxW = 500.0f; + float boxH = 350.0f; + float boxX = (LOGICAL_W - boxW) * 0.5f; + float boxY = (LOGICAL_H - boxH) * 0.5f; + + SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); + SDL_FRect boxRect{boxX + contentOffsetX, boxY + contentOffsetY, boxW, boxH}; + SDL_RenderFillRect(renderer, &boxRect); + + SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255); + SDL_FRect borderRect{boxX + contentOffsetX - 3, boxY + contentOffsetY - 3, boxW + 6, boxH + 6}; + SDL_RenderFillRect(renderer, &borderRect); + SDL_SetRenderDrawColor(renderer, 20, 25, 35, 255); + SDL_RenderFillRect(renderer, &boxRect); + + ensureScoresLoaded(); + // Choose display values based on mode (single-player vs coop) + int displayScore = 0; + int displayLines = 0; + int displayLevel = 0; + if (game && game->getMode() == GameMode::Cooperate && coopGame) { + int leftScore = coopGame->score(CoopGame::PlayerSide::Left); + int rightScore = coopGame->score(CoopGame::PlayerSide::Right); + displayScore = leftScore + rightScore; + displayLines = coopGame->lines(); + displayLevel = coopGame->level(); + } else if (game) { + displayScore = game->score(); + displayLines = game->lines(); + displayLevel = game->level(); + } + + bool realHighScore = scores.isHighScore(displayScore); + const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER"; + int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH); + pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255}); + + char scoreStr[64]; + snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", displayScore); + int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH); + pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255}); + + if (isNewHighScore) { + const bool isCoopEntry = (game && game->getMode() == GameMode::Cooperate && coopGame); + const char* enterName = isCoopEntry ? "ENTER NAMES:" : "ENTER NAME:"; + int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH); + if (!isCoopEntry) { + pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255}); + } + + const float inputW = isCoopEntry ? 260.0f : 300.0f; + const float inputH = 40.0f; + const float inputX = boxX + (boxW - inputW) * 0.5f; + const float inputY = boxY + 200.0f; + + const float nameScale = 1.2f; + const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0; + + int metricsW = 0, metricsH = 0; + pixelFont.measure("A", nameScale, metricsW, metricsH); + if (metricsH == 0) metricsH = 24; + + // Single name entry (non-coop) --- keep original behavior + if (!isCoopEntry) { + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH}; + SDL_RenderFillRect(renderer, &inputRect); + SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255); + SDL_RenderRect(renderer, &inputRect); + + int nameW = 0, nameH = 0; + if (!playerName.empty()) pixelFont.measure(playerName, nameScale, nameW, nameH); + else nameH = metricsH; + + float textX = inputX + (inputW - static_cast(nameW)) * 0.5f + contentOffsetX; + float textY = inputY + (inputH - static_cast(metricsH)) * 0.5f + contentOffsetY; + + if (!playerName.empty()) pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255,255,255,255}); + + if (showCursor) { + int cursorW = 0, cursorH = 0; pixelFont.measure("_", nameScale, cursorW, cursorH); + float cursorX = playerName.empty() ? inputX + (inputW - static_cast(cursorW)) * 0.5f + contentOffsetX : textX + static_cast(nameW); + float cursorY = inputY + (inputH - static_cast(cursorH)) * 0.5f + contentOffsetY; + pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255}); + } + + const char* hint = "PRESS ENTER TO SUBMIT"; + int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH); + pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255}); + } else { + // Coop: prompt sequentially. First ask Player 1, then ask Player 2 after Enter. + const bool askingP1 = (highScoreEntryIndex == 0); + const char* label = askingP1 ? "PLAYER 1:" : "PLAYER 2:"; + int labW=0, labH=0; pixelFont.measure(label, 1.0f, labW, labH); + pixelFont.draw(renderer, boxX + (boxW - labW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, label, 1.0f, {200,200,220,255}); + + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_FRect rect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH}; + SDL_RenderFillRect(renderer, &rect); + SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255); + SDL_RenderRect(renderer, &rect); + + const std::string &activeName = askingP1 ? playerName : player2Name; + int nameW = 0, nameH = 0; + if (!activeName.empty()) pixelFont.measure(activeName, nameScale, nameW, nameH); + else nameH = metricsH; + + float textX = inputX + (inputW - static_cast(nameW)) * 0.5f + contentOffsetX; + float textY = inputY + (inputH - static_cast(metricsH)) * 0.5f + contentOffsetY; + if (!activeName.empty()) pixelFont.draw(renderer, textX, textY, activeName, nameScale, {255,255,255,255}); + + if (showCursor) { + int cursorW=0, cursorH=0; pixelFont.measure("_", nameScale, cursorW, cursorH); + float cursorX = activeName.empty() ? inputX + (inputW - static_cast(cursorW)) * 0.5f + contentOffsetX : textX + static_cast(nameW); + float cursorY = inputY + (inputH - static_cast(cursorH)) * 0.5f + contentOffsetY; + pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255}); + } + + const char* hint = askingP1 ? "PRESS ENTER FOR NEXT NAME" : "PRESS ENTER TO SUBMIT"; + int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH); + pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 300 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255}); + } + + } else { + char linesStr[64]; + snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines()); + int lW=0, lH=0; pixelFont.measure(linesStr, 1.2f, lW, lH); + pixelFont.draw(renderer, boxX + (boxW - lW) * 0.5f + contentOffsetX, boxY + 140 + contentOffsetY, linesStr, 1.2f, {255, 255, 255, 255}); + + char levelStr[64]; + snprintf(levelStr, sizeof(levelStr), "LEVEL: %d", game->level()); + int lvW=0, lvH=0; pixelFont.measure(levelStr, 1.2f, lvW, lvH); + pixelFont.draw(renderer, boxX + (boxW - lvW) * 0.5f + contentOffsetX, boxY + 180 + contentOffsetY, levelStr, 1.2f, {255, 255, 255, 255}); + + const char* instr = "PRESS ENTER TO RESTART"; + int iW=0, iH=0; pixelFont.measure(instr, 0.9f, iW, iH); + pixelFont.draw(renderer, boxX + (boxW - iW) * 0.5f + contentOffsetX, boxY + 260 + contentOffsetY, instr, 0.9f, {255, 220, 0, 255}); + + const char* instr2 = "PRESS ESC FOR MENU"; + int iW2=0, iH2=0; pixelFont.measure(instr2, 0.9f, iW2, iH2); + pixelFont.draw(renderer, boxX + (boxW - iW2) * 0.5f + contentOffsetX, boxY + 290 + contentOffsetY, instr2, 0.9f, {255, 220, 0, 255}); + } + } + break; + } + + if (menuFadeAlpha > 0.0f) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.f, 1.f); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + Uint8 alpha = Uint8(std::clamp(menuFadeAlpha, 0.0f, 1.0f) * 255.0f); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, alpha); + SDL_FRect fadeRect{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &fadeRect); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + } + + if (gameplayCountdownActive && state == AppState::Playing) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.f, 1.f); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + int cappedIndex = std::min(gameplayCountdownIndex, static_cast(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); + const char* label = GAMEPLAY_COUNTDOWN_LABELS[cappedIndex]; + bool isFinalCue = (cappedIndex == static_cast(GAMEPLAY_COUNTDOWN_LABELS.size()) - 1); + float textScale = isFinalCue ? 4.5f : 5.0f; + int textW = 0, textH = 0; + pixelFont.measure(label, textScale, textW, textH); + + float textX = (winW - static_cast(textW)) * 0.5f; + float textY = (winH - static_cast(textH)) * 0.5f; + 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); + } + + if (showHelpOverlay) { + SDL_SetRenderViewport(renderer, &logicalVP); + SDL_SetRenderScale(renderer, logicalScale, logicalScale); + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + if (logicalScale > 0.0f) { + float scaledW = LOGICAL_W * logicalScale; + float scaledH = LOGICAL_H * logicalScale; + contentOffsetX = (winW - scaledW) * 0.5f / logicalScale; + contentOffsetY = (winH - scaledH) * 0.5f / logicalScale; + } + HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY); + } + + if (startupFadeActive && startupFadeAlpha > 0.0f) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.0f, 1.0f); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + const Uint8 a = (Uint8)std::clamp((int)std::lround(startupFadeAlpha * 255.0f), 0, 255); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, a); + SDL_FRect full{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &full); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + } + + SDL_RenderPresent(renderer); + SDL_SetRenderScale(renderer, 1.f, 1.f); + } +} + +void TetrisApp::Impl::shutdown() +{ + Settings::instance().save(); + + // BackgroundManager owns its own textures. + levelBackgrounds.reset(); + + // All textures are owned by AssetLoader (including legacy fallbacks adopted above). + logoTex = nullptr; + logoSmallTex = nullptr; + backgroundTex = nullptr; + mainScreenTex = nullptr; + blocksTex = nullptr; + scorePanelTex = nullptr; + statisticsPanelTex = nullptr; + nextPanelTex = nullptr; + + if (scoreLoader.joinable()) { + scoreLoader.join(); + if (!ctx.scores) { + ctx.scores = &scores; + } + } + if (menuTrackLoader.joinable()) { + menuTrackLoader.join(); + } + + lineEffect.shutdown(); + Audio::instance().shutdown(); + SoundEffectManager::instance().shutdown(); + + // Destroy textures before tearing down the renderer/window. + assetLoader.shutdown(); + + pixelFont.shutdown(); + font.shutdown(); + + TTF_Quit(); + + if (renderer) { + SDL_DestroyRenderer(renderer); + renderer = nullptr; + } + if (window) { + SDL_DestroyWindow(window); + window = nullptr; + } + SDL_Quit(); +} diff --git a/src/app/TetrisApp.h b/src/app/TetrisApp.h new file mode 100644 index 0000000..1b795d0 --- /dev/null +++ b/src/app/TetrisApp.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// TetrisApp is the top-level application orchestrator. +// +// Responsibilities: +// - SDL/TTF init + shutdown +// - Asset/music loading + loading screen +// - Main loop + state transitions +// +// It uses a PIMPL to keep `TetrisApp.h` light (faster builds) and to avoid leaking +// SDL-heavy includes into every translation unit. +class TetrisApp { +public: + TetrisApp(); + ~TetrisApp(); + + TetrisApp(const TetrisApp&) = delete; + TetrisApp& operator=(const TetrisApp&) = delete; + + // Runs the application until exit is requested. + // Returns a non-zero exit code on initialization failure. + int run(); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/src/app/TextureLoader.cpp b/src/app/TextureLoader.cpp new file mode 100644 index 0000000..ef9af8e --- /dev/null +++ b/src/app/TextureLoader.cpp @@ -0,0 +1,91 @@ +#include "app/TextureLoader.h" + +#include + +#include +#include +#include + +#include "utils/ImagePathResolver.h" + +TextureLoader::TextureLoader( + std::atomic& loadedTasks, + std::string& currentLoadingFile, + std::mutex& currentLoadingMutex, + std::vector& assetLoadErrors, + std::mutex& assetLoadErrorsMutex) + : loadedTasks_(loadedTasks) + , currentLoadingFile_(currentLoadingFile) + , currentLoadingMutex_(currentLoadingMutex) + , assetLoadErrors_(assetLoadErrors) + , assetLoadErrorsMutex_(assetLoadErrorsMutex) +{ +} + +void TextureLoader::setCurrentLoadingFile(const std::string& filename) { + std::lock_guard lk(currentLoadingMutex_); + currentLoadingFile_ = filename; +} + +void TextureLoader::clearCurrentLoadingFile() { + std::lock_guard lk(currentLoadingMutex_); + currentLoadingFile_.clear(); +} + +void TextureLoader::recordAssetLoadError(const std::string& message) { + std::lock_guard lk(assetLoadErrorsMutex_); + assetLoadErrors_.emplace_back(message); +} + +SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW, int* outH) { + if (!renderer) { + return nullptr; + } + + const std::string resolvedPath = AssetPath::resolveImagePath(path); + setCurrentLoadingFile(resolvedPath.empty() ? path : resolvedPath); + + SDL_Surface* surface = IMG_Load(resolvedPath.c_str()); + if (!surface) { + { + std::ostringstream ss; + ss << "Image load failed: " << path << " (" << resolvedPath << "): " << SDL_GetError(); + recordAssetLoadError(ss.str()); + } + loadedTasks_.fetch_add(1); + clearCurrentLoadingFile(); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + if (outW) { + *outW = surface->w; + } + if (outH) { + *outH = surface->h; + } + + SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_DestroySurface(surface); + + if (!texture) { + { + std::ostringstream ss; + ss << "Texture create failed: " << resolvedPath << ": " << SDL_GetError(); + recordAssetLoadError(ss.str()); + } + loadedTasks_.fetch_add(1); + clearCurrentLoadingFile(); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError()); + return nullptr; + } + + loadedTasks_.fetch_add(1); + clearCurrentLoadingFile(); + + if (resolvedPath != path) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str()); + } + + return texture; +} diff --git a/src/app/TextureLoader.h b/src/app/TextureLoader.h new file mode 100644 index 0000000..d807fe7 --- /dev/null +++ b/src/app/TextureLoader.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include +#include +#include + +class TextureLoader { +public: + TextureLoader( + std::atomic& loadedTasks, + std::string& currentLoadingFile, + std::mutex& currentLoadingMutex, + std::vector& assetLoadErrors, + std::mutex& assetLoadErrorsMutex); + + SDL_Texture* loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr); + +private: + std::atomic& loadedTasks_; + std::string& currentLoadingFile_; + std::mutex& currentLoadingMutex_; + std::vector& assetLoadErrors_; + std::mutex& assetLoadErrorsMutex_; + + void setCurrentLoadingFile(const std::string& filename); + void clearCurrentLoadingFile(); + void recordAssetLoadError(const std::string& message); +}; diff --git a/src/audio/Audio.cpp b/src/audio/Audio.cpp index 38ecf48..582f7b5 100644 --- a/src/audio/Audio.cpp +++ b/src/audio/Audio.cpp @@ -20,6 +20,15 @@ #pragma comment(lib, "mfuuid.lib") #pragma comment(lib, "ole32.lib") using Microsoft::WRL::ComPtr; +#ifdef max +#undef max +#endif +#ifdef min +#undef min +#endif +#elif defined(__APPLE__) +#include +#include #endif Audio& Audio::instance(){ static Audio inst; return inst; } @@ -30,7 +39,7 @@ bool Audio::init(){ if(outSpec.freq!=0) return true; outSpec.format=SDL_AUDIO_S1 #endif return true; } -#ifdef _WIN32 +#if defined(_WIN32) static bool decodeMP3(const std::string& path, std::vector& outPCM, int& outRate, int& outCh){ outPCM.clear(); outRate=44100; outCh=2; ComPtr reader; @@ -41,15 +50,85 @@ static bool decodeMP3(const std::string& path, std::vector& outPCM, int reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE); while(true){ DWORD flags=0; ComPtr sample; if(FAILED(reader->ReadSample(MF_SOURCE_READER_FIRST_AUDIO_STREAM,0,nullptr,&flags,nullptr,&sample))) break; if(flags & MF_SOURCE_READERF_ENDOFSTREAM) break; if(!sample) continue; ComPtr buffer; if(FAILED(sample->ConvertToContiguousBuffer(&buffer))) continue; BYTE* data=nullptr; DWORD maxLen=0, curLen=0; if(SUCCEEDED(buffer->Lock(&data,&maxLen,&curLen)) && curLen){ size_t samples = curLen/2; size_t oldSz = outPCM.size(); outPCM.resize(oldSz + samples); std::memcpy(outPCM.data()+oldSz, data, curLen); } if(data) buffer->Unlock(); } outRate=44100; outCh=2; return !outPCM.empty(); } +#elif defined(__APPLE__) +// Decode MP3 files using macOS AudioToolbox so music works on Apple builds. +static bool decodeMP3(const std::string& path, std::vector& outPCM, int& outRate, int& outCh){ + outPCM.clear(); + outRate = 44100; + outCh = 2; + + CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast(path.c_str()), path.size(), false); + if (!url) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to create URL for %s", path.c_str()); + return false; + } + + ExtAudioFileRef audioFile = nullptr; + OSStatus status = ExtAudioFileOpenURL(url, &audioFile); + CFRelease(url); + if (status != noErr || !audioFile) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] ExtAudioFileOpenURL failed (%d) for %s", static_cast(status), path.c_str()); + return false; + } + + AudioStreamBasicDescription clientFormat{}; + clientFormat.mSampleRate = 44100.0; + clientFormat.mFormatID = kAudioFormatLinearPCM; + clientFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked; + clientFormat.mBitsPerChannel = 16; + clientFormat.mChannelsPerFrame = 2; + clientFormat.mFramesPerPacket = 1; + clientFormat.mBytesPerFrame = (clientFormat.mBitsPerChannel / 8) * clientFormat.mChannelsPerFrame; + clientFormat.mBytesPerPacket = clientFormat.mBytesPerFrame * clientFormat.mFramesPerPacket; + + status = ExtAudioFileSetProperty(audioFile, kExtAudioFileProperty_ClientDataFormat, sizeof(clientFormat), &clientFormat); + if (status != noErr) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to set client format (%d) for %s", static_cast(status), path.c_str()); + ExtAudioFileDispose(audioFile); + return false; + } + + const UInt32 framesPerBuffer = 4096; + std::vector buffer(framesPerBuffer * clientFormat.mChannelsPerFrame); + while (true) { + AudioBufferList abl{}; + abl.mNumberBuffers = 1; + abl.mBuffers[0].mNumberChannels = clientFormat.mChannelsPerFrame; + abl.mBuffers[0].mDataByteSize = framesPerBuffer * clientFormat.mBytesPerFrame; + abl.mBuffers[0].mData = buffer.data(); + + UInt32 framesToRead = framesPerBuffer; + status = ExtAudioFileRead(audioFile, &framesToRead, &abl); + if (status != noErr) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] ExtAudioFileRead failed (%d) for %s", static_cast(status), path.c_str()); + ExtAudioFileDispose(audioFile); + return false; + } + + if (framesToRead == 0) { + break; // EOF + } + + size_t samplesRead = static_cast(framesToRead) * clientFormat.mChannelsPerFrame; + outPCM.insert(outPCM.end(), buffer.data(), buffer.data() + samplesRead); + } + + ExtAudioFileDispose(audioFile); + outRate = static_cast(clientFormat.mSampleRate); + outCh = static_cast(clientFormat.mChannelsPerFrame); + return !outPCM.empty(); +} +#else +static bool decodeMP3(const std::string& path, std::vector& outPCM, int& outRate, int& outCh){ + (void)outPCM; (void)outRate; (void)outCh; (void)path; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported on this platform: %s", path.c_str()); + return false; +} #endif void Audio::addTrack(const std::string& path){ AudioTrack t; t.path=path; -#ifdef _WIN32 - if(decodeMP3(path, t.pcm, t.rate, t.channels)) t.ok=true; else SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str()); -#else - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported on this platform (stub): %s", path.c_str()); -#endif - tracks.push_back(std::move(t)); } + if(decodeMP3(path, t.pcm, t.rate, t.channels)) t.ok=true; else SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str()); + tracks.push_back(std::move(t)); } void Audio::shuffle(){ std::lock_guard lock(tracksMutex); @@ -58,19 +137,63 @@ void Audio::shuffle(){ bool Audio::ensureStream(){ if(audioStream) return true; + // Ensure audio spec is initialized + if (!init()) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to initialize audio spec before opening device stream"); + return false; + } audioStream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &outSpec, &Audio::streamCallback, this); if(!audioStream){ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] SDL_OpenAudioDeviceStream failed: %s", SDL_GetError()); return false; } + // Ensure the device is running so SFX can be heard even before music starts + SDL_ResumeAudioStreamDevice(audioStream); return true; } -void Audio::start(){ if(!ensureStream()) return; if(!playing){ current=-1; nextTrack(); SDL_ResumeAudioStreamDevice(audioStream); playing=true; } } +void Audio::start(){ + if(!ensureStream()) return; + // If no track is selected yet, try to select one now (in case tracks loaded after initial start) + if(current < 0) { + nextTrack(); + } + SDL_ResumeAudioStreamDevice(audioStream); + playing = true; +} + +void Audio::skipToNextTrack(){ + if(!ensureStream()) return; + + // If menu music is active, just restart the looped menu track + if(isMenuMusic){ + if(menuTrack.ok){ + menuTrack.cursor = 0; + playing = true; + SDL_ResumeAudioStreamDevice(audioStream); + } + return; + } + + if(tracks.empty()) return; + nextTrack(); + playing = true; + SDL_ResumeAudioStreamDevice(audioStream); +} void Audio::toggleMute(){ muted=!muted; } +void Audio::setMuted(bool m){ muted=m; } -void Audio::nextTrack(){ if(tracks.empty()) return; for(size_t i=0;i mix(outSamples, 0); // 1) Mix music into buffer (if not muted) - if(!muted && current >= 0){ + // 1) Mix music into buffer (if not muted) + if(!muted && playing){ size_t cursorBytes = 0; while(cursorBytes < bytesWanted){ - if(current < 0) break; - auto &trk = tracks[current]; - size_t samplesAvail = trk.pcm.size() - trk.cursor; // samples (int16) - if(samplesAvail == 0){ nextTrack(); if(current < 0) break; continue; } + AudioTrack* trk = nullptr; + + if (isMenuMusic) { + if (menuTrack.ok) trk = &menuTrack; + } else { + if (current >= 0 && current < (int)tracks.size()) trk = &tracks[current]; + } + + if (!trk) break; + + size_t samplesAvail = trk->pcm.size() - trk->cursor; // samples (int16) + if(samplesAvail == 0){ + if (isMenuMusic) { + trk->cursor = 0; // Loop menu music + continue; + } else { + nextTrack(); + if(current < 0) break; + continue; + } + } + size_t samplesNeeded = (bytesWanted - cursorBytes) / sizeof(int16_t); size_t toCopy = (samplesAvail < samplesNeeded) ? samplesAvail : samplesNeeded; if(toCopy == 0) break; + // Mix add with clamp size_t startSample = cursorBytes / sizeof(int16_t); for(size_t i=0;ipcm[trk->cursor+i]; if(v>32767) v=32767; if(v<-32768) v=-32768; mix[startSample+i] = (int16_t)v; } - trk.cursor += toCopy; + trk->cursor += toCopy; cursorBytes += (Uint32)(toCopy * sizeof(int16_t)); - if(trk.cursor >= trk.pcm.size()) nextTrack(); + + if(trk->cursor >= trk->pcm.size()) { + if (isMenuMusic) { + trk->cursor = 0; // Loop menu music + } else { + nextTrack(); + } + } } } @@ -156,7 +306,16 @@ void Audio::addTrackAsync(const std::string& path) { } void Audio::startBackgroundLoading() { - if (loadingThread.joinable()) return; // Already running + // If a previous loading thread exists but has finished, join it so we can start anew + if (loadingThread.joinable()) { + if (loadingComplete) { + loadingThread.join(); + } else { + // Already running + return; + } + } + loadingAbort = false; loadingComplete = false; loadedCount = 0; loadingThread = std::thread(&Audio::backgroundLoadingThread, this); @@ -174,36 +333,46 @@ void Audio::backgroundLoadingThread() { } #endif - // Copy pending tracks to avoid holding the mutex during processing - std::vector tracksToProcess; - { - std::lock_guard lock(pendingTracksMutex); - tracksToProcess = pendingTracks; - } - - for (const std::string& path : tracksToProcess) { + while (true) { + if (loadingAbort.load()) { + break; + } + std::string path; + { + std::lock_guard lock(pendingTracksMutex); + if (pendingTracks.empty()) break; + path = std::move(pendingTracks.front()); + pendingTracks.erase(pendingTracks.begin()); + if (loadingAbort.load()) { + break; + } + } AudioTrack t; t.path = path; -#ifdef _WIN32 - if (mfInitialized && decodeMP3(path, t.pcm, t.rate, t.channels)) { + if (decodeMP3(path, t.pcm, t.rate, t.channels)) { t.ok = true; } else { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode %s", path.c_str()); } -#else - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] MP3 unsupported on this platform (stub): %s", path.c_str()); -#endif // Thread-safe addition to tracks + if (loadingAbort.load()) { + break; + } + { std::lock_guard lock(tracksMutex); tracks.push_back(std::move(t)); } - loadedCount++; + loadedCount++; - // Small delay to prevent overwhelming the system - std::this_thread::sleep_for(std::chrono::milliseconds(10)); + // Small delay to prevent overwhelming the system (unless abort requested) + if (!loadingAbort.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } else { + break; + } } #ifdef _WIN32 @@ -233,11 +402,43 @@ int Audio::getLoadedTrackCount() const { return loadedCount; } +void Audio::setMenuTrack(const std::string& path) { + menuTrack.path = path; + if (decodeMP3(path, menuTrack.pcm, menuTrack.rate, menuTrack.channels)) { + menuTrack.ok = true; + } else { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[Audio] Failed to decode menu track %s", path.c_str()); + } +} + +void Audio::playMenuMusic() { + isMenuMusic = true; + if (menuTrack.ok) { + menuTrack.cursor = 0; + } + start(); +} + +void Audio::playGameMusic() { + isMenuMusic = false; + // If we were playing menu music, we might want to pick a random track or resume + if (current < 0 && !tracks.empty()) { + nextTrack(); + } + start(); +} + void Audio::shutdown(){ // Stop background loading thread first + loadingAbort = true; + { + std::lock_guard lock(pendingTracksMutex); + pendingTracks.clear(); + } if (loadingThread.joinable()) { loadingThread.join(); } + loadingComplete = true; if(audioStream){ SDL_DestroyAudioStream(audioStream); audioStream=nullptr; } tracks.clear(); @@ -250,3 +451,40 @@ void Audio::shutdown(){ if(mfStarted){ MFShutdown(); mfStarted=false; } #endif } + +// IAudioSystem interface implementation +void Audio::playSound(const std::string& name) { + // This is a simplified implementation - in a full implementation, + // you would load sound effects by name from assets + // For now, we'll just trigger a generic sound effect + // In practice, this would load a sound file and play it via playSfx +} + +void Audio::playMusic(const std::string& name) { + // This is a simplified implementation - in a full implementation, + // you would load music tracks by name + // For now, we'll just start the current playlist + if (!tracks.empty() && !playing) { + start(); + } +} + +void Audio::stopMusic() { + playing = false; +} + +void Audio::setMasterVolume(float volume) { + m_masterVolume = std::max(0.0f, std::min(1.0f, volume)); +} + +void Audio::setMusicVolume(float volume) { + m_musicVolume = std::max(0.0f, std::min(1.0f, volume)); +} + +void Audio::setSoundVolume(float volume) { + m_sfxVolume = std::max(0.0f, std::min(1.0f, volume)); +} + +bool Audio::isMusicPlaying() const { + return playing; +} diff --git a/src/audio/Audio.h b/src/audio/Audio.h index 7275b3d..35f520a 100644 --- a/src/audio/Audio.h +++ b/src/audio/Audio.h @@ -8,6 +8,7 @@ #include #include #include +#include "../core/interfaces/IAudioSystem.h" struct AudioTrack { std::string path; @@ -18,9 +19,20 @@ struct AudioTrack { bool ok = false; }; -class Audio { +class Audio : public IAudioSystem { public: static Audio& instance(); + + // IAudioSystem interface implementation + void playSound(const std::string& name) override; + void playMusic(const std::string& name) override; + void stopMusic() override; + void setMasterVolume(float volume) override; + void setMusicVolume(float volume) override; + void setSoundVolume(float volume) override; + bool isMusicPlaying() const override; + + // Existing Audio class methods bool init(); // initialize backend (MF on Windows) void addTrack(const std::string& path); // decode MP3 -> PCM16 stereo 44100 void addTrackAsync(const std::string& path); // add track for background loading @@ -30,7 +42,16 @@ public: int getLoadedTrackCount() const; // get number of tracks loaded so far void shuffle(); // randomize order void start(); // begin playback + void skipToNextTrack(); // advance to the next music track void toggleMute(); + void setMuted(bool m); + bool isMuted() const { return muted; } + + // Menu music support + void setMenuTrack(const std::string& path); + void playMenuMusic(); + void playGameMusic(); + // Queue a sound effect to mix over the music (pcm can be mono/stereo, any rate; will be converted) void playSfx(const std::vector& pcm, int channels, int rate, float volume); void shutdown(); @@ -42,7 +63,10 @@ private: bool ensureStream(); void backgroundLoadingThread(); // background thread function - std::vector tracks; int current=-1; bool playing=false; bool muted=false; std::mt19937 rng{std::random_device{}()}; + std::vector tracks; + AudioTrack menuTrack; + bool isMenuMusic = false; + int current=-1; bool playing=false; bool muted=false; std::mt19937 rng{std::random_device{}()}; SDL_AudioStream* audioStream=nullptr; SDL_AudioSpec outSpec{}; int outChannels=2; int outRate=44100; bool mfStarted=false; // Threading support @@ -51,10 +75,16 @@ private: std::mutex tracksMutex; std::mutex pendingTracksMutex; std::atomic loadingComplete{false}; + std::atomic loadingAbort{false}; std::atomic loadedCount{0}; // SFX mixing support struct SfxPlay { std::vector pcm; size_t cursor=0; }; std::vector activeSfx; std::mutex sfxMutex; + + // Volume control + float m_masterVolume = 1.0f; + float m_musicVolume = 1.0f; + float m_sfxVolume = 1.0f; }; diff --git a/src/audio/MenuWrappers.h b/src/audio/MenuWrappers.h index 68847af..ad2e98c 100644 --- a/src/audio/MenuWrappers.h +++ b/src/audio/MenuWrappers.h @@ -12,11 +12,7 @@ void menu_updateFireworks(double frameMs); double menu_getLogoAnimCounter(); int menu_getHoveredButton(); -void menu_drawEnhancedButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, bool isHovered, bool isSelected); - -void menu_drawMenuButton(SDL_Renderer* renderer, FontAtlas& font, float cx, float cy, float w, float h, - const std::string& label, SDL_Color bgColor, SDL_Color borderColor); - -void menu_drawLevelSelectionPopup(SDL_Renderer* renderer, FontAtlas& font, SDL_Texture* bgTex, int selectedLevel); -void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicEnabled); +// Legacy wrappers removed +// void menu_drawEnhancedButton(...); +// void menu_drawMenuButton(...); +// void menu_drawSettingsPopup(...); diff --git a/src/audio/SoundEffect.cpp b/src/audio/SoundEffect.cpp index 24317bd..ecc34ee 100644 --- a/src/audio/SoundEffect.cpp +++ b/src/audio/SoundEffect.cpp @@ -20,6 +20,9 @@ #pragma comment(lib, "mfuuid.lib") #pragma comment(lib, "ole32.lib") using Microsoft::WRL::ComPtr; +#elif defined(__APPLE__) +#include +#include #endif // SoundEffect implementation @@ -43,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; } @@ -52,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)); @@ -143,7 +143,7 @@ bool SoundEffect::loadWAV(const std::string& filePath) { } bool SoundEffect::loadMP3(const std::string& filePath) { -#ifdef _WIN32 +#if defined(_WIN32) static bool mfInitialized = false; if (!mfInitialized) { if (FAILED(MFStartup(MF_VERSION))) { @@ -222,6 +222,67 @@ bool SoundEffect::loadMP3(const std::string& filePath) { channels = 2; sampleRate = 44100; return true; +#elif defined(__APPLE__) + CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast(filePath.c_str()), filePath.size(), false); + if (!url) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] Failed to create URL for %s", filePath.c_str()); + return false; + } + + ExtAudioFileRef audioFile = nullptr; + OSStatus status = ExtAudioFileOpenURL(url, &audioFile); + CFRelease(url); + if (status != noErr || !audioFile) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] ExtAudioFileOpenURL failed (%d) for %s", static_cast(status), filePath.c_str()); + return false; + } + + AudioStreamBasicDescription clientFormat{}; + clientFormat.mSampleRate = 44100.0; + clientFormat.mFormatID = kAudioFormatLinearPCM; + clientFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked; + clientFormat.mBitsPerChannel = 16; + clientFormat.mChannelsPerFrame = 2; + clientFormat.mFramesPerPacket = 1; + clientFormat.mBytesPerFrame = (clientFormat.mBitsPerChannel / 8) * clientFormat.mChannelsPerFrame; + clientFormat.mBytesPerPacket = clientFormat.mBytesPerFrame * clientFormat.mFramesPerPacket; + + status = ExtAudioFileSetProperty(audioFile, kExtAudioFileProperty_ClientDataFormat, sizeof(clientFormat), &clientFormat); + if (status != noErr) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] Failed to set client format (%d) for %s", static_cast(status), filePath.c_str()); + ExtAudioFileDispose(audioFile); + return false; + } + + const UInt32 framesPerBuffer = 2048; + std::vector buffer(framesPerBuffer * clientFormat.mChannelsPerFrame); + while (true) { + AudioBufferList abl{}; + abl.mNumberBuffers = 1; + abl.mBuffers[0].mNumberChannels = clientFormat.mChannelsPerFrame; + abl.mBuffers[0].mDataByteSize = framesPerBuffer * clientFormat.mBytesPerFrame; + abl.mBuffers[0].mData = buffer.data(); + + UInt32 framesToRead = framesPerBuffer; + status = ExtAudioFileRead(audioFile, &framesToRead, &abl); + if (status != noErr) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] ExtAudioFileRead failed (%d) for %s", static_cast(status), filePath.c_str()); + ExtAudioFileDispose(audioFile); + return false; + } + + if (framesToRead == 0) { + break; // EOF + } + + size_t samplesRead = static_cast(framesToRead) * clientFormat.mChannelsPerFrame; + pcmData.insert(pcmData.end(), buffer.data(), buffer.data() + samplesRead); + } + + ExtAudioFileDispose(audioFile); + channels = static_cast(clientFormat.mChannelsPerFrame); + sampleRate = static_cast(clientFormat.mSampleRate); + return !pcmData.empty(); #else SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[SoundEffect] MP3 support not available on this platform"); return false; diff --git a/src/core/Config.h b/src/core/Config.h new file mode 100644 index 0000000..9d8ea3d --- /dev/null +++ b/src/core/Config.h @@ -0,0 +1,163 @@ +#pragma once + +#include + +/** + * Config - Centralized configuration constants + * + * Replaces magic numbers and scattered constants throughout main.cpp + * Organized by functional area for easy maintenance and modification + */ +namespace Config { + + // Window and Display Settings + namespace Window { + constexpr int DEFAULT_WIDTH = 1200; + constexpr int DEFAULT_HEIGHT = 1000; + constexpr const char* DEFAULT_TITLE = "SpaceTris (SDL3)"; + constexpr bool DEFAULT_VSYNC = true; + } + + // Logical rendering dimensions (internal coordinate system) + namespace Logical { + constexpr int WIDTH = 1200; + constexpr int HEIGHT = 1000; + } + + // Gameplay constants + namespace Gameplay { + constexpr double DAS_DELAY = 170.0; // Delayed Auto Shift delay in ms + constexpr double ARR_RATE = 40.0; // Auto Repeat Rate in ms + constexpr float LEVEL_FADE_DURATION = 5000.0f; // Level background fade time in ms + constexpr int MAX_LEVELS = 20; // Maximum selectable starting level + + // Gravity speed multiplier: 1.0 = normal, 2.0 = 2x slower, 0.5 = 2x faster + constexpr double GRAVITY_SPEED_MULTIPLIER = 2; // increase drop interval by ~100% to slow gravity + } + + // UI Layout constants + namespace UI { + constexpr float MIN_MARGIN = 40.0f; + constexpr float PANEL_WIDTH = 180.0f; + constexpr float PANEL_SPACING = 30.0f; + constexpr float BUTTON_HEIGHT_SMALL = 60.0f; + constexpr float BUTTON_HEIGHT_NORMAL = 70.0f; + constexpr float BUTTON_WIDTH_SMALL = 0.4f; // Fraction of screen width + constexpr float BUTTON_WIDTH_NORMAL = 300.0f; + constexpr float SETTINGS_GEAR_SIZE = 50.0f; + constexpr float SETTINGS_GEAR_MARGIN = 10.0f; + + // Screen size breakpoints + constexpr float SMALL_SCREEN_BREAKPOINT = 700.0f; + + // Menu positioning + constexpr float MENU_BUTTON_Y_OFFSET = 40.0f; + constexpr float MENU_BUTTON_Y_BASE = 0.86f; // Fraction of screen height + } + + // Loading screen constants + namespace Loading { + constexpr float LOGO_HEIGHT_FACTOR_LIMITED = 0.25f; // When height < 450 + constexpr float LOGO_HEIGHT_FACTOR_NORMAL = 0.4f; + constexpr float LOGO_MAX_WIDTH_FACTOR = 0.9f; // Fraction of screen width + constexpr float LOGO_MAX_WIDTH_ABSOLUTE = 600.0f; + constexpr int LOGO_ORIGINAL_WIDTH = 872; + constexpr int LOGO_ORIGINAL_HEIGHT = 273; + constexpr float LOADING_TEXT_HEIGHT = 20.0f; + constexpr float LOADING_BAR_HEIGHT = 20.0f; + constexpr float LOADING_BAR_PADDING_LIMITED = 15.0f; + constexpr float LOADING_BAR_PADDING_NORMAL = 35.0f; + constexpr float LOADING_PERCENTAGE_HEIGHT = 24.0f; + constexpr float LOADING_ELEMENT_SPACING_LIMITED = 5.0f; + constexpr float LOADING_ELEMENT_SPACING_NORMAL = 15.0f; + constexpr int LIMITED_HEIGHT_THRESHOLD = 450; + } + + // Animation constants + namespace Animation { + constexpr double LOGO_ANIM_SPEED = 0.0008; // Logo animation speed multiplier + constexpr float STARFIELD_UPDATE_DIVISOR = 1000.0f; // Convert ms to seconds + } + + // HUD and Game Display + namespace HUD { + constexpr float GRAVITY_DISPLAY_X = 260.0f; // Distance from right edge + constexpr float GRAVITY_DISPLAY_Y = 10.0f; + constexpr float SCORE_PANEL_X_OFFSET = 120.0f; // Distance from center + constexpr float SCORE_PANEL_BASE_Y = 220.0f; + constexpr float CONTROLS_HINT_Y_OFFSET = 30.0f; // Distance from bottom + } + + // Popup and Modal constants + namespace Popup { + constexpr float EXIT_CONFIRM_WIDTH = 400.0f; + constexpr float EXIT_CONFIRM_HEIGHT = 200.0f; + constexpr float SETTINGS_POPUP_WIDTH = 300.0f; + constexpr float SETTINGS_POPUP_HEIGHT = 250.0f; + } + + // Color definitions (commonly used colors) + namespace Colors { + constexpr SDL_Color WHITE = {255, 255, 255, 255}; + constexpr SDL_Color BLACK = {0, 0, 0, 255}; + constexpr SDL_Color YELLOW_TITLE = {255, 220, 0, 255}; + constexpr SDL_Color GRAY_TEXT = {220, 220, 230, 255}; + constexpr SDL_Color BLUE_HIGHLIGHT = {200, 240, 255, 255}; + constexpr SDL_Color RED_ERROR = {255, 80, 60, 255}; + constexpr SDL_Color GREEN_SUCCESS = {0, 255, 0, 255}; + constexpr SDL_Color RED_DISABLED = {255, 0, 0, 255}; + constexpr SDL_Color CONTROL_HINT = {150, 150, 170, 255}; + constexpr SDL_Color PAUSED_TEXT = {255, 255, 255, 255}; + constexpr SDL_Color PAUSED_HINT = {200, 200, 220, 255}; + constexpr SDL_Color SHADOW = {0, 0, 0, 200}; + } + + // Font configuration + namespace Fonts { + constexpr int DEFAULT_FONT_SIZE = 24; + constexpr int PIXEL_FONT_SIZE = 16; + constexpr const char* DEFAULT_FONT_PATH = "FreeSans.ttf"; + constexpr const char* PIXEL_FONT_PATH = "assets/fonts/PressStart2P-Regular.ttf"; + } + + // Asset paths + namespace Assets { + constexpr const char* IMAGES_DIR = "assets/images/"; + constexpr const char* MUSIC_DIR = "assets/music/"; + constexpr const char* FONTS_DIR = "assets/fonts/"; + + // Specific asset files + constexpr const char* LOGO_BMP = "assets/images/logo.bmp"; + constexpr const char* LOGO_SMALL_BMP = "assets/images/logo_small.bmp"; + constexpr const char* BACKGROUND_BMP = "assets/images/main_background.bmp"; + constexpr const char* BLOCKS_BMP = "assets/images/2.png"; + } + + // Audio settings + namespace Audio { + constexpr float DEFAULT_VOLUME = 1.0f; + constexpr float LETS_GO_VOLUME = 1.0f; + constexpr int MAX_MUSIC_TRACKS = 100; // Maximum number of music files to scan + } + + // Input settings + namespace Input { + constexpr int MOUSE_BUTTON_PRIMARY = SDL_BUTTON_LEFT; + constexpr Uint32 MODIFIER_SHIFT = SDL_KMOD_SHIFT; + constexpr Uint32 MODIFIER_CTRL = SDL_KMOD_CTRL; + } + + // Performance settings + namespace Performance { + constexpr float MIN_FRAME_TIME = 0.05f; // Cap at 20 FPS minimum (prevent spiral of death) + constexpr int STARFIELD_PARTICLE_COUNT = 200; + constexpr int STARFIELD_3D_PARTICLE_COUNT = 200; + } + + // Visual effects settings + namespace Visuals { + constexpr int PAUSE_BLUR_ITERATIONS = 8; // Number of blur passes (higher = more blur) + constexpr int PAUSE_BLUR_OFFSET = 3; // Pixel spread of the blur + constexpr int PAUSE_BLUR_ALPHA = 40; // Alpha intensity of blur layers + } +} diff --git a/src/core/GlobalState.cpp b/src/core/GlobalState.cpp new file mode 100644 index 0000000..b5ad912 --- /dev/null +++ b/src/core/GlobalState.cpp @@ -0,0 +1,420 @@ +#include "GlobalState.h" +#include "Config.h" +#include +#include +#include +#include + +namespace { +constexpr float PI_F = 3.14159265358979323846f; + +float randRange(float minVal, float maxVal) { + return minVal + (static_cast(rand()) / static_cast(RAND_MAX)) * (maxVal - minVal); +} + +SDL_Color randomFireworkColor() { + static const SDL_Color palette[] = { + {255, 120, 80, 255}, + {255, 190, 60, 255}, + {120, 210, 255, 255}, + {170, 120, 255, 255}, + {255, 90, 180, 255}, + {120, 255, 170, 255}, + {255, 255, 180, 255} + }; + size_t idx = static_cast(rand() % (sizeof(palette) / sizeof(palette[0]))); + return palette[idx]; +} + +SDL_Color scaleColor(SDL_Color color, float factor, Uint8 alphaOverride = 0) { + auto clampChannel = [](float value) -> Uint8 { + return static_cast(std::max(0.0f, std::min(255.0f, std::round(value)))); + }; + SDL_Color result; + result.r = clampChannel(color.r * factor); + result.g = clampChannel(color.g * factor); + result.b = clampChannel(color.b * factor); + result.a = alphaOverride ? alphaOverride : color.a; + return result; +} + +SDL_Color mixColors(SDL_Color a, SDL_Color b, float t) { + t = std::clamp(t, 0.0f, 1.0f); + auto lerpChannel = [t](Uint8 ca, Uint8 cb) -> Uint8 { + float blended = ca + (cb - ca) * t; + return static_cast(std::max(0.0f, std::min(255.0f, blended))); + }; + SDL_Color result; + result.r = lerpChannel(a.r, b.r); + result.g = lerpChannel(a.g, b.g); + result.b = lerpChannel(a.b, b.b); + result.a = lerpChannel(a.a, b.a); + return result; +} +} + +GlobalState& GlobalState::instance() { + static GlobalState instance; + return instance; +} + +void GlobalState::initialize() { + if (m_initialized) { + return; + } + + // Initialize timing + lastMs = SDL_GetTicks(); + loadStart = SDL_GetTicks(); + + // Initialize viewport to logical dimensions + logicalVP = {0, 0, Config::Logical::WIDTH, Config::Logical::HEIGHT}; + + // Initialize fireworks system + fireworks.clear(); + lastFireworkTime = 0; + + m_initialized = true; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[GlobalState] Initialized"); +} + +void GlobalState::shutdown() { + if (!m_initialized) { + return; + } + + // Clear fireworks + fireworks.clear(); + + m_initialized = false; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[GlobalState] Shutdown complete"); +} + +namespace { +using Firework = GlobalState::TetrisFirework; +using BlockParticle = GlobalState::BlockParticle; +using SparkParticle = GlobalState::SparkParticle; +void spawnSparks(Firework& firework, float cx, float cy, SDL_Color baseColor, float speedBase) { + int sparkCount = 10 + (rand() % 10); + for (int i = 0; i < sparkCount; ++i) { + SparkParticle spark; + spark.x = cx; + spark.y = cy; + float angle = randRange(0.0f, PI_F * 2.0f); + float speed = speedBase * randRange(1.05f, 1.6f); + spark.vx = std::cos(angle) * speed; + spark.vy = std::sin(angle) * speed - randRange(30.0f, 90.0f); + spark.life = 0.0f; + spark.maxLife = 260.0f + randRange(0.0f, 200.0f); + spark.thickness = randRange(0.8f, 2.2f); + spark.color = scaleColor(baseColor, randRange(0.85f, 1.2f), 255); + firework.sparks.push_back(spark); + } +} + +void triggerFireworkBurst(Firework& firework, int burstIndex) { + SDL_Color burstColor = firework.burstColors[burstIndex % firework.burstColors.size()]; + float centerX = firework.originX + randRange(-30.0f, 30.0f); + float centerY = firework.originY - burstIndex * randRange(14.0f, 24.0f) + randRange(-10.0f, 10.0f); + int particleCount = 22 + (rand() % 16); + float speedBase = 90.0f + burstIndex * 40.0f; + + for (int i = 0; i < particleCount; ++i) { + BlockParticle particle; + particle.x = centerX; + particle.y = centerY; + float angle = randRange(0.0f, PI_F * 2.0f); + float speed = speedBase + randRange(-20.0f, 70.0f); + particle.vx = std::cos(angle) * speed; + particle.vy = std::sin(angle) * speed - randRange(35.0f, 95.0f); + particle.maxLife = 950.0f + randRange(0.0f, 420.0f) + burstIndex * 220.0f; + particle.life = particle.maxLife; + particle.crackle = (rand() % 100) < 75; + particle.flickerSeed = randRange(0.0f, PI_F * 2.0f); + if (particle.crackle) { + particle.size = randRange(1.4f, 3.2f); + } else { + particle.size = 3.2f + randRange(0.0f, 2.6f) + burstIndex * 0.6f; + } + particle.color = scaleColor(burstColor, randRange(0.85f, 1.2f)); + particle.dualColor = (rand() % 100) < 55; + if (particle.dualColor) { + SDL_Color alt = randomFireworkColor(); + float luminanceDiff = std::abs(static_cast(alt.r + alt.g + alt.b) - (particle.color.r + particle.color.g + particle.color.b)); + if (luminanceDiff < 40.0f) { + alt = scaleColor(particle.color, randRange(0.6f, 1.4f)); + } + particle.accentColor = alt; + particle.colorBlendSpeed = randRange(0.6f, 1.4f); + } else { + particle.accentColor = particle.color; + particle.colorBlendSpeed = 1.0f; + } + firework.particles.push_back(particle); + } + + spawnSparks(firework, centerX, centerY, burstColor, speedBase); +} +} + +void GlobalState::updateFireworks(double frameMs) { + if (frameMs <= 0.0) { + frameMs = 16.0; + } + + const Uint64 currentTime = SDL_GetTicks(); + size_t activeCount = 0; + for (const auto& fw : fireworks) { + if (fw.active) { + ++activeCount; + } + } + + constexpr size_t MAX_SIMULTANEOUS_FIREWORKS = 2; + bool canSpawnNew = activeCount < MAX_SIMULTANEOUS_FIREWORKS; + bool spawnedFirework = false; + + if (canSpawnNew) { + Uint64 interval = 1300 + static_cast(rand() % 1400); + if (currentTime - lastFireworkTime > interval) { + float x = Config::Logical::WIDTH * (0.15f + randRange(0.0f, 0.70f)); + float y = Config::Logical::HEIGHT * (0.18f + randRange(0.0f, 0.40f)); + createFirework(x, y); + lastFireworkTime = currentTime; + lastFireworkX = x; + lastFireworkY = y; + pendingStaggerFirework = (rand() % 100) < 65; + if (pendingStaggerFirework) { + nextStaggerFireworkTime = currentTime + 250 + static_cast(rand() % 420); + } + spawnedFirework = true; + } + } + + if (!spawnedFirework && pendingStaggerFirework && canSpawnNew && currentTime >= nextStaggerFireworkTime) { + float x = lastFireworkX + randRange(-140.0f, 140.0f); + float y = lastFireworkY + randRange(-80.0f, 50.0f); + x = std::clamp(x, Config::Logical::WIDTH * 0.10f, Config::Logical::WIDTH * 0.90f); + y = std::clamp(y, Config::Logical::HEIGHT * 0.15f, Config::Logical::HEIGHT * 0.70f); + createFirework(x, y); + lastFireworkTime = currentTime; + lastFireworkX = x; + lastFireworkY = y; + pendingStaggerFirework = false; + spawnedFirework = true; + } + + const float dtSeconds = static_cast(frameMs / 1000.0); + const float deltaMs = static_cast(frameMs); + + for (auto& firework : fireworks) { + if (!firework.active) { + continue; + } + + firework.elapsedMs += deltaMs; + while (firework.nextBurst < static_cast(firework.burstSchedule.size()) && + firework.elapsedMs >= firework.burstSchedule[firework.nextBurst]) { + triggerFireworkBurst(firework, firework.nextBurst); + firework.nextBurst++; + } + + for (auto it = firework.particles.begin(); it != firework.particles.end();) { + it->life -= deltaMs; + if (it->life <= 0.0f) { + it = firework.particles.erase(it); + continue; + } + it->x += it->vx * dtSeconds; + it->y += it->vy * dtSeconds; + it->vx *= 0.986f; + it->vy = it->vy * 0.972f + 70.0f * dtSeconds; + ++it; + } + + for (auto it = firework.sparks.begin(); it != firework.sparks.end();) { + it->life += deltaMs; + if (it->life >= it->maxLife) { + it = firework.sparks.erase(it); + continue; + } + it->x += it->vx * dtSeconds; + it->y += it->vy * dtSeconds; + it->vx *= 0.992f; + it->vy = it->vy * 0.965f + 120.0f * dtSeconds; + ++it; + } + + bool pendingBursts = firework.nextBurst < static_cast(firework.burstSchedule.size()); + firework.active = pendingBursts || !firework.particles.empty() || !firework.sparks.empty(); + } +} + +void GlobalState::createFirework(float x, float y) { + // Find an inactive firework to reuse + TetrisFirework* firework = nullptr; + for (auto& fw : fireworks) { + if (!fw.active) { + firework = &fw; + break; + } + } + + // If no inactive firework found, create a new one + if (!firework) { + fireworks.emplace_back(); + firework = &fireworks.back(); + } + + firework->active = true; + firework->particles.clear(); + firework->sparks.clear(); + firework->originX = x; + firework->originY = y; + firework->elapsedMs = 0.0f; + firework->nextBurst = 0; + firework->burstSchedule = { + 0.0f, + 220.0f + randRange(0.0f, 160.0f), + 420.0f + randRange(0.0f, 260.0f) + }; + + SDL_Color baseColor = randomFireworkColor(); + for (int i = 0; i < 3; ++i) { + float wobble = randRange(-0.08f, 0.08f); + firework->burstColors[i] = scaleColor(baseColor, 1.0f - i * 0.12f + wobble, 255); + } +} + +void GlobalState::drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) { + (void)blocksTex; + + auto renderCircle = [renderer](float cx, float cy, float radius) { + int ir = static_cast(std::ceil(radius)); + for (int dy = -ir; dy <= ir; ++dy) { + float row = std::sqrt(std::max(0.0f, radius * radius - static_cast(dy * dy))); + SDL_FRect line{cx - row, cy + dy, row * 2.0f, 1.0f}; + SDL_RenderFillRect(renderer, &line); + } + }; + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD); + for (const auto& firework : fireworks) { + if (!firework.active) continue; + + for (const auto& spark : firework.sparks) { + if (spark.life >= spark.maxLife) continue; + float progress = spark.life / spark.maxLife; + Uint8 alpha = static_cast((1.0f - progress) * 255.0f); + if (alpha == 0) continue; + SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha); + float trailScale = 0.015f * spark.thickness; + float tailX = spark.x - spark.vx * trailScale; + float tailY = spark.y - spark.vy * trailScale; + SDL_RenderLine(renderer, + static_cast(spark.x), + static_cast(spark.y), + static_cast(tailX), + static_cast(tailY)); + } + } + + auto sampleParticleColor = [](const BlockParticle& particle) -> SDL_Color { + if (!particle.dualColor) { + return particle.color; + } + float elapsed = particle.maxLife - particle.life; + float phase = particle.flickerSeed * 1.8f + elapsed * 0.0025f * particle.colorBlendSpeed; + float mixFactor = 0.5f + 0.5f * std::sin(phase); + return mixColors(particle.color, particle.accentColor, mixFactor); + }; + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + for (const auto& firework : fireworks) { + if (!firework.active) continue; + + for (const auto& particle : firework.particles) { + if (particle.life <= 0.0f) continue; + float lifeRatio = particle.life / particle.maxLife; + float alphaF = std::pow(std::max(0.0f, lifeRatio), 0.75f); + if (particle.crackle) { + SDL_Color dynamicColor = sampleParticleColor(particle); + float flicker = 0.55f + 0.45f * std::sin(particle.flickerSeed + particle.life * 0.018f); + Uint8 alpha = static_cast(alphaF * flicker * 255.0f); + if (alpha == 0) continue; + SDL_SetRenderDrawColor(renderer, dynamicColor.r, dynamicColor.g, dynamicColor.b, alpha); + float stretch = particle.size * (2.5f + (1.0f - lifeRatio) * 1.3f); + float angle = particle.flickerSeed * 3.0f + particle.life * 0.004f; + float dx = std::cos(angle) * stretch; + float dy = std::sin(angle) * stretch * 0.7f; + SDL_RenderLine(renderer, + static_cast(particle.x - dx), + static_cast(particle.y - dy), + static_cast(particle.x + dx), + static_cast(particle.y + dy)); + SDL_RenderLine(renderer, + static_cast(particle.x - dy * 0.45f), + static_cast(particle.y + dx * 0.45f), + static_cast(particle.x + dy * 0.45f), + static_cast(particle.y - dx * 0.45f)); + } else { + SDL_Color dynamicColor = sampleParticleColor(particle); + Uint8 alpha = static_cast(alphaF * 255.0f); + SDL_SetRenderDrawColor(renderer, dynamicColor.r, dynamicColor.g, dynamicColor.b, alpha); + float radius = particle.size * (0.5f + 0.3f * lifeRatio); + renderCircle(particle.x, particle.y, radius); + } + } + } + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); +} + +void GlobalState::resetGameState() { + // Reset game-related state + leftHeld = false; + rightHeld = false; + moveTimerMs = 0.0; + startLevelSelection = 0; +} + +void GlobalState::resetUIState() { + // Reset UI state + showSettingsPopup = false; + showExitConfirmPopup = false; + hoveredButton = -1; +} + +void GlobalState::resetAnimationState() { + // Reset animation state + logoAnimCounter = 0.0; + fireworks.clear(); + lastFireworkTime = 0; +} + +void GlobalState::updateLogicalDimensions(int windowWidth, int windowHeight) { + // For now, keep logical dimensions proportional to window size + // You can adjust this logic based on your specific needs + + // Option 1: Keep fixed aspect ratio and scale uniformly + const float targetAspect = static_cast(Config::Logical::WIDTH) / static_cast(Config::Logical::HEIGHT); + const float windowAspect = static_cast(windowWidth) / static_cast(windowHeight); + + if (windowAspect > targetAspect) { + // Window is wider than target aspect - fit to height + currentLogicalHeight = Config::Logical::HEIGHT; + currentLogicalWidth = static_cast(currentLogicalHeight * windowAspect); + } else { + // Window is taller than target aspect - fit to width + currentLogicalWidth = Config::Logical::WIDTH; + currentLogicalHeight = static_cast(currentLogicalWidth / windowAspect); + } + + // Ensure minimum sizes + currentLogicalWidth = std::max(currentLogicalWidth, 800); + currentLogicalHeight = std::max(currentLogicalHeight, 600); + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "[GlobalState] Updated logical dimensions: %dx%d (window: %dx%d)", + currentLogicalWidth, currentLogicalHeight, windowWidth, windowHeight); +} diff --git a/src/core/GlobalState.h b/src/core/GlobalState.h new file mode 100644 index 0000000..d3df436 --- /dev/null +++ b/src/core/GlobalState.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include + +// Forward declarations +class FontAtlas; +class Game; +class ScoreManager; +class Starfield; +class Starfield3D; +class LineEffect; + +/** + * GlobalState - Centralized management of application-wide state + * + * Replaces global variables scattered throughout main.cpp + * Provides controlled access to shared state between systems + * Will eventually be replaced by proper dependency injection + */ +class GlobalState { +public: + // Singleton access (temporary until dependency injection is implemented) + static GlobalState& instance(); + + // Initialization and cleanup + void initialize(); + void shutdown(); + + // Application state flags + bool running = true; + bool isFullscreen = false; + bool musicEnabled = true; + bool musicStarted = false; + bool musicLoaded = false; + + // UI state flags + bool showSettingsPopup = false; + bool showExitConfirmPopup = false; + int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings + + // Input state (will be migrated to InputManager) + bool leftHeld = false; + bool rightHeld = false; + double moveTimerMs = 0.0; + + // Loading state + double loadingProgress = 0.0; + int currentTrackLoading = 0; + int totalTracks = 0; + int startLevelSelection = 0; + + // Timing + Uint64 lastMs = 0; + Uint64 loadStart = 0; + + // Animation state + double logoAnimCounter = 0.0; + + // Level background caching + int cachedLevel = -1; + float levelFadeAlpha = 0.0f; + float levelFadeElapsed = 0.0f; + + // Viewport and scaling + SDL_Rect logicalVP{0, 0, 1200, 1000}; // Will use Config::Logical constants + float logicalScale = 1.0f; + + // Dynamic logical dimensions (computed from window size) + int currentLogicalWidth = 1200; + int currentLogicalHeight = 1000; + + // Fireworks system (for menu animation) + struct BlockParticle { + float x = 0.0f; + float y = 0.0f; + float vx = 0.0f; + float vy = 0.0f; + float size = 0.0f; + float life = 0.0f; + float maxLife = 0.0f; + SDL_Color color{255, 255, 255, 255}; + SDL_Color accentColor{255, 255, 255, 255}; + int generation = 0; + bool hasExploded = false; + bool crackle = false; + float flickerSeed = 0.0f; + bool dualColor = false; + float colorBlendSpeed = 0.0f; + }; + + struct SparkParticle { + float x = 0.0f; + float y = 0.0f; + float vx = 0.0f; + float vy = 0.0f; + float life = 0.0f; + float maxLife = 0.0f; + float thickness = 1.0f; + SDL_Color color{255, 255, 255, 255}; + }; + + struct TetrisFirework { + std::vector particles; + std::vector sparks; + bool active = false; + float originX = 0.0f; + float originY = 0.0f; + float elapsedMs = 0.0f; + int nextBurst = 0; + std::array burstSchedule{0.0f, 250.0f, 520.0f}; + std::array burstColors{}; + }; + + std::vector fireworks; + Uint64 lastFireworkTime = 0; + bool pendingStaggerFirework = false; + Uint64 nextStaggerFireworkTime = 0; + float lastFireworkX = 0.0f; + float lastFireworkY = 0.0f; + + // Fireworks management methods + void updateFireworks(double frameMs); + void createFirework(float x, float y); + void drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex); + + // Logical dimensions management + void updateLogicalDimensions(int windowWidth, int windowHeight); + int getLogicalWidth() const { return currentLogicalWidth; } + int getLogicalHeight() const { return currentLogicalHeight; } + + // Reset methods for different states + void resetGameState(); + void resetUIState(); + void resetAnimationState(); + +private: + GlobalState() = default; + ~GlobalState() = default; + GlobalState(const GlobalState&) = delete; + GlobalState& operator=(const GlobalState&) = delete; + + bool m_initialized = false; +}; + +// Convenience accessors (temporary until proper dependency injection) +namespace Globals { + inline GlobalState& state() { return GlobalState::instance(); } + + // Quick access to commonly used flags + inline bool& running() { return state().running; } + inline bool& musicEnabled() { return state().musicEnabled; } + inline bool& showSettingsPopup() { return state().showSettingsPopup; } + inline int& hoveredButton() { return state().hoveredButton; } + inline double& logoAnimCounter() { return state().logoAnimCounter; } +} diff --git a/src/core/GravityManager.cpp b/src/core/GravityManager.cpp index 7bfc238..e3b10b1 100644 --- a/src/core/GravityManager.cpp +++ b/src/core/GravityManager.cpp @@ -14,19 +14,19 @@ double GravityManager::getGlobalMultiplier() const { return globalMultiplier; } void GravityManager::setLevelMultiplier(int level, double m) { if (level < 0) return; - int idx = level >= 29 ? 29 : level; + int idx = level >= 19 ? 19 : level; levelMultipliers[idx] = std::clamp(m, 0.01, 100.0); } double GravityManager::getLevelMultiplier(int level) const { - int idx = level < 0 ? 0 : (level >= 29 ? 29 : level); + int idx = level < 0 ? 0 : (level >= 19 ? 19 : level); return levelMultipliers[idx]; } double GravityManager::getMsForLevel(int level) const { - int idx = level < 0 ? 0 : (level >= 29 ? 29 : level); - double frames = static_cast(FRAMES_TABLE[idx]) * levelMultipliers[idx]; - double result = frames * FRAME_MS * globalMultiplier; + int idx = level < 0 ? 0 : (level >= 19 ? 19 : level); + double baseMs = LEVEL_SPEEDS_MS[idx]; + double result = baseMs * levelMultipliers[idx] * globalMultiplier; return std::max(1.0, result); } diff --git a/src/core/GravityManager.h b/src/core/GravityManager.h index a2c05e8..e75d767 100644 --- a/src/core/GravityManager.h +++ b/src/core/GravityManager.h @@ -18,14 +18,11 @@ public: double getFpsForLevel(int level) const; private: - static constexpr double NES_FPS = 60.0988; - static constexpr double FRAME_MS = 1000.0 / NES_FPS; - static constexpr int FRAMES_TABLE[30] = { - 48,43,38,33,28,23,18,13,8,6, - 5,5,5,4,4,4,3,3,3,2, - 2,2,2,2,2,2,2,2,2,1 + static constexpr double LEVEL_SPEEDS_MS[20] = { + 1000.0, 920.0, 840.0, 760.0, 680.0, 600.0, 520.0, 440.0, 360.0, 280.0, + 200.0, 160.0, 160.0, 120.0, 120.0, 100.0, 100.0, 80.0, 80.0, 60.0 }; double globalMultiplier{1.0}; - std::array levelMultipliers{}; // default 1.0 + std::array levelMultipliers{}; // default 1.0 }; diff --git a/src/core/ServiceContainer.h b/src/core/ServiceContainer.h new file mode 100644 index 0000000..4809487 --- /dev/null +++ b/src/core/ServiceContainer.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include + +/** + * @brief Dependency injection container for managing services + * + * Provides a centralized way to register and retrieve services, + * enabling loose coupling and better testability. + */ +class ServiceContainer { +private: + std::unordered_map> services_; + +public: + /** + * @brief Register a service instance + * @tparam T Service type + * @param service Shared pointer to service instance + */ + template + void registerService(std::shared_ptr service) { + services_[std::type_index(typeid(T))] = service; + } + + /** + * @brief Get a service instance + * @tparam T Service type + * @return Shared pointer to service instance + * @throws std::runtime_error if service is not registered + */ + template + std::shared_ptr getService() { + auto it = services_.find(std::type_index(typeid(T))); + if (it != services_.end()) { + return std::static_pointer_cast(it->second); + } + throw std::runtime_error("Service not registered: " + std::string(typeid(T).name())); + } + + /** + * @brief Get a service instance (const version) + * @tparam T Service type + * @return Shared pointer to service instance + * @throws std::runtime_error if service is not registered + */ + template + std::shared_ptr getService() const { + auto it = services_.find(std::type_index(typeid(T))); + if (it != services_.end()) { + return std::static_pointer_cast(it->second); + } + throw std::runtime_error("Service not registered: " + std::string(typeid(T).name())); + } + + /** + * @brief Check if a service is registered + * @tparam T Service type + * @return true if service is registered, false otherwise + */ + template + bool hasService() const { + return services_.find(std::type_index(typeid(T))) != services_.end(); + } + + /** + * @brief Unregister a service + * @tparam T Service type + */ + template + void unregisterService() { + services_.erase(std::type_index(typeid(T))); + } + + /** + * @brief Clear all registered services + */ + void clear() { + services_.clear(); + } + + /** + * @brief Get the number of registered services + * @return Number of registered services + */ + size_t getServiceCount() const { + return services_.size(); + } +}; diff --git a/src/core/Settings.cpp b/src/core/Settings.cpp new file mode 100644 index 0000000..3982a4f --- /dev/null +++ b/src/core/Settings.cpp @@ -0,0 +1,126 @@ +#include "Settings.h" +#include +#include +#include + +// Singleton instance +Settings& Settings::instance() { + static Settings s_instance; + return s_instance; +} + +Settings::Settings() { + // Constructor - defaults already set in header +} + +std::string Settings::getSettingsPath() { + // Save settings.ini in the game's directory + return "settings.ini"; +} + +bool Settings::load() { + std::ifstream file(getSettingsPath()); + if (!file.is_open()) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults. Creating settings file with defaults."); + // Persist defaults so next run has an explicit settings.ini + try { + save(); + } catch (...) {} + return false; + } + + std::string line; + std::string currentSection; + + while (std::getline(file, line)) { + // Trim whitespace + size_t start = line.find_first_not_of(" \t\r\n"); + size_t end = line.find_last_not_of(" \t\r\n"); + if (start == std::string::npos) continue; // Empty line + line = line.substr(start, end - start + 1); + + // Skip comments + if (line[0] == ';' || line[0] == '#') continue; + + // Check for section header + if (line[0] == '[' && line[line.length() - 1] == ']') { + currentSection = line.substr(1, line.length() - 2); + continue; + } + + // Parse key=value + size_t equalsPos = line.find('='); + if (equalsPos == std::string::npos) continue; + + std::string key = line.substr(0, equalsPos); + std::string value = line.substr(equalsPos + 1); + + // Trim key and value + key.erase(key.find_last_not_of(" \t") + 1); + value.erase(0, value.find_first_not_of(" \t")); + + // Parse settings + if (currentSection == "Display") { + if (key == "Fullscreen") { + m_fullscreen = (value == "1" || value == "true" || value == "True"); + } + } else if (currentSection == "Audio") { + if (key == "Music") { + m_musicEnabled = (value == "1" || value == "true" || value == "True"); + } else if (key == "Sound") { + m_soundEnabled = (value == "1" || value == "true" || value == "True"); + } + } else if (currentSection == "Gameplay") { + if (key == "SmoothScroll") { + m_smoothScrollEnabled = (value == "1" || value == "true" || value == "True"); + } else if (key == "UpRotateClockwise") { + m_upRotateClockwise = (value == "1" || value == "true" || value == "True"); + } + } else if (currentSection == "Player") { + if (key == "Name") { + m_playerName = value; + } + } else if (currentSection == "Debug") { + if (key == "Enabled") { + m_debugEnabled = (value == "1" || value == "true" || value == "True"); + } + } + } + + file.close(); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings loaded from %s", getSettingsPath().c_str()); + return true; +} + +bool Settings::save() { + std::ofstream file(getSettingsPath()); + if (!file.is_open()) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to save settings to %s", getSettingsPath().c_str()); + return false; + } + + // Write settings in INI format + file << "; Tetris Game Settings\n"; + file << "; This file is auto-generated\n\n"; + + file << "[Display]\n"; + file << "Fullscreen=" << (m_fullscreen ? "1" : "0") << "\n\n"; + + file << "[Audio]\n"; + file << "Music=" << (m_musicEnabled ? "1" : "0") << "\n"; + file << "Sound=" << (m_soundEnabled ? "1" : "0") << "\n\n"; + + file << "[Gameplay]\n"; + file << "SmoothScroll=" << (m_smoothScrollEnabled ? "1" : "0") << "\n\n"; + file << "UpRotateClockwise=" << (m_upRotateClockwise ? "1" : "0") << "\n\n"; + + file << "[Player]\n"; + file << "Name=" << m_playerName << "\n\n"; + + file << "[Debug]\n"; + file << "Enabled=" << (m_debugEnabled ? "1" : "0") << "\n"; + + file.close(); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings saved to %s", getSettingsPath().c_str()); + return true; +} diff --git a/src/core/Settings.h b/src/core/Settings.h new file mode 100644 index 0000000..6e158a4 --- /dev/null +++ b/src/core/Settings.h @@ -0,0 +1,60 @@ +#pragma once +#include + +/** + * Settings - Persistent game settings manager + * Handles loading/saving settings to settings.ini + */ +class Settings { +public: + // Singleton access + static Settings& instance(); + + // Load settings from file (returns true if file existed) + bool load(); + + // Save settings to file + bool save(); + + // Settings accessors + bool isFullscreen() const { return m_fullscreen; } + void setFullscreen(bool value) { m_fullscreen = value; } + + bool isMusicEnabled() const { return m_musicEnabled; } + void setMusicEnabled(bool value) { m_musicEnabled = value; } + + bool isSoundEnabled() const { return m_soundEnabled; } + void setSoundEnabled(bool value) { m_soundEnabled = value; } + + bool isDebugEnabled() const { return m_debugEnabled; } + void setDebugEnabled(bool value) { m_debugEnabled = value; } + + bool isSmoothScrollEnabled() const { return m_smoothScrollEnabled; } + void setSmoothScrollEnabled(bool value) { m_smoothScrollEnabled = value; } + + // Rotation behavior: should pressing UP rotate clockwise? (true = clockwise) + bool isUpRotateClockwise() const { return m_upRotateClockwise; } + void setUpRotateClockwise(bool value) { m_upRotateClockwise = value; } + + const std::string& getPlayerName() const { return m_playerName; } + void setPlayerName(const std::string& name) { m_playerName = name; } + + // Get the settings file path + static std::string getSettingsPath(); + +private: + Settings(); // Private constructor for singleton + Settings(const Settings&) = delete; + Settings& operator=(const Settings&) = delete; + + // Settings values + // Default to fullscreen on first run when no settings.ini exists + bool m_fullscreen = true; + bool m_musicEnabled = true; + bool m_soundEnabled = true; + bool m_debugEnabled = false; + bool m_smoothScrollEnabled = true; + std::string m_playerName = "Player"; + // Default: UP rotates clockwise + bool m_upRotateClockwise = true; +}; diff --git a/src/core/StateManager.cpp b/src/core/StateManager.cpp deleted file mode 100644 index cfb0fee..0000000 --- a/src/core/StateManager.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "StateManager.h" - -StateManager::StateManager(AppState initial) - : currentState(initial) -{ -} - -void StateManager::registerHandler(AppState s, EventHandler h) { - handlers[static_cast(s)].push_back(std::move(h)); -} - -void StateManager::registerOnEnter(AppState s, Hook h) { - onEnter[static_cast(s)].push_back(std::move(h)); -} - -void StateManager::registerOnExit(AppState s, Hook h) { - onExit[static_cast(s)].push_back(std::move(h)); -} - -// Overload accepting a no-arg function as handler (wraps it into an EventHandler) -void StateManager::registerHandler(AppState s, std::function h) { - EventHandler wrapper = [h = std::move(h)](const SDL_Event&) { h(); }; - registerHandler(s, std::move(wrapper)); -} - -void StateManager::setState(AppState s) { - if (s == currentState) return; - // call exit hooks for current - auto it = onExit.find(static_cast(currentState)); - if (it != onExit.end()) { - for (auto &h : it->second) h(); - } - currentState = s; - auto it2 = onEnter.find(static_cast(currentState)); - if (it2 != onEnter.end()) { - for (auto &h : it2->second) h(); - } -} - -AppState StateManager::getState() const { return currentState; } - -void StateManager::handleEvent(const SDL_Event& e) { - auto it = handlers.find(static_cast(currentState)); - if (it == handlers.end()) return; - for (auto &h : it->second) h(e); -} diff --git a/src/core/StateManager.h b/src/core/StateManager.h deleted file mode 100644 index 8f9bcf1..0000000 --- a/src/core/StateManager.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -// Application states used across the app -enum class AppState { - Loading, - Menu, - Playing, - LevelSelect, - GameOver -}; - -// State manager used by main to route events and lifecycle hooks -class StateManager { -public: - using EventHandler = std::function; - using Hook = std::function; - - StateManager(AppState initial); - - void registerHandler(AppState s, EventHandler h); - void registerOnEnter(AppState s, Hook h); - void registerOnExit(AppState s, Hook h); - - void registerHandler(AppState s, std::function h); // overload used in some places - - void setState(AppState s); - AppState getState() const; - - void handleEvent(const SDL_Event& e); - -private: - AppState currentState; - std::unordered_map> handlers; - std::unordered_map> onEnter; - std::unordered_map> onExit; -}; diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp new file mode 100644 index 0000000..9e4da60 --- /dev/null +++ b/src/core/application/ApplicationManager.cpp @@ -0,0 +1,1681 @@ +#include "ApplicationManager.h" +#include "../state/StateManager.h" +#include "../input/InputManager.h" +#include "../interfaces/IAudioSystem.h" +#include "../interfaces/IRenderer.h" +#include "../interfaces/IAssetLoader.h" +#include "../interfaces/IInputHandler.h" +#include +#include "../../audio/Audio.h" +#include "../../audio/SoundEffect.h" +#include "../../persistence/Scores.h" +#include "../../states/State.h" +#include "../../states/LoadingState.h" +#include "../../states/MenuState.h" +#include "../../states/OptionsState.h" +#include "../../states/LevelSelectorState.h" +#include "../../states/PlayingState.h" +#include "../assets/AssetManager.h" +#include "../Config.h" +#include "../GlobalState.h" +#include "../../graphics/renderers/RenderManager.h" +#include "../../graphics/ui/Font.h" +#include "../../graphics/ui/HelpOverlay.h" +#include "../../graphics/effects/Starfield3D.h" +#include "../../graphics/effects/Starfield.h" +#include "../../graphics/renderers/GameRenderer.h" +#include "../../gameplay/core/Game.h" +#include "../../gameplay/coop/CoopGame.h" +#include "../../gameplay/effects/LineEffect.h" +#include +#include +#include +#include "../../utils/ImagePathResolver.h" +#include +#include "../../video/VideoPlayer.h" +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif +// (Intro video playback is now handled in-process via VideoPlayer) + +ApplicationManager::ApplicationManager() = default; + +static void traceFile(const char* msg) { + std::ofstream f("spacetris_trace.log", std::ios::app); + if (f) f << msg << "\n"; +} + +// Helper: extracted from inline lambda to avoid MSVC parsing issues with complex lambdas +void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& renderer) { + // Clear background first + renderer.clear(0, 0, 0, 255); + + // Use 3D starfield for loading screen (full screen) + if (app->m_starfield3D) { + int winW_actual = 0, winH_actual = 0; + if (app->m_renderManager) app->m_renderManager->getWindowSize(winW_actual, winH_actual); + if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual); + app->m_starfield3D->draw(renderer.getSDLRenderer()); + } + // If intro video is playing, render it instead of the loading UI + if (app->m_introStarted && app->m_videoPlayer) { + SDL_Renderer* sdlR = renderer.getSDLRenderer(); + int winW=0, winH=0; renderer.getWindowSize(winW, winH); + app->m_videoPlayer->render(sdlR, winW, winH); + SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr); + SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f); + return; + } + SDL_Rect logicalVP = {0,0,0,0}; + float logicalScale = 1.0f; + if (app->m_renderManager) { + logicalVP = app->m_renderManager->getLogicalViewport(); + logicalScale = app->m_renderManager->getLogicalScale(); + } + SDL_SetRenderViewport(renderer.getSDLRenderer(), &logicalVP); + SDL_SetRenderScale(renderer.getSDLRenderer(), logicalScale, logicalScale); + + float contentOffsetX = 0.0f; + float contentOffsetY = 0.0f; + + auto drawRectOriginal = [&](float x, float y, float w, float h, SDL_Color c) { + SDL_SetRenderDrawColor(renderer.getSDLRenderer(), c.r, c.g, c.b, c.a); + SDL_FRect fr; + fr.x = x + contentOffsetX; + fr.y = y + contentOffsetY; + fr.w = w; + fr.h = h; + SDL_RenderFillRect(renderer.getSDLRenderer(), &fr); + }; + + // Compute dynamic logical width/height based on the RenderManager's + // computed viewport and scale so the loading UI sizes itself to the + // actual content area rather than a hardcoded design size. + float LOGICAL_W = static_cast(Config::Logical::WIDTH); + float LOGICAL_H = static_cast(Config::Logical::HEIGHT); + if (logicalScale > 0.0f && logicalVP.w > 0 && logicalVP.h > 0) { + // logicalVP is in window pixels; divide by scale to get logical units + LOGICAL_W = static_cast(logicalVP.w) / logicalScale; + LOGICAL_H = static_cast(logicalVP.h) / logicalScale; + } + const bool isLimitedHeight = LOGICAL_H < 450.0f; + SDL_Texture* logoTex = app->m_assetManager->getTexture("logo"); + const float logoHeight = logoTex ? (isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f) : 0; + const float loadingTextHeight = 20; + const float barHeight = 20; + const float barPaddingVertical = isLimitedHeight ? 15 : 35; + const float percentTextHeight = 24; + const float spacingBetweenElements = isLimitedHeight ? 5 : 15; + + const float totalContentHeight = logoHeight + (logoHeight > 0 ? spacingBetweenElements : 0) + loadingTextHeight + barPaddingVertical + barHeight + spacingBetweenElements + percentTextHeight; + + float currentY = (LOGICAL_H - totalContentHeight) / 2.0f; + + if (logoTex) { + const int lw = 872, lh = 273; + const float maxLogoWidth = std::min(LOGICAL_W * 0.9f, 600.0f); + const float availableHeight = isLimitedHeight ? LOGICAL_H * 0.25f : LOGICAL_H * 0.4f; + const float availableWidth = maxLogoWidth; + const float scaleFactorWidth = availableWidth / static_cast(lw); + const float scaleFactorHeight = availableHeight / static_cast(lh); + const float scaleFactor = std::min(scaleFactorWidth, scaleFactorHeight); + const float displayWidth = lw * scaleFactor; + const float displayHeight = lh * scaleFactor; + const float logoX = (LOGICAL_W - displayWidth) / 2.0f; + SDL_FRect dst{logoX + contentOffsetX, currentY + contentOffsetY, displayWidth, displayHeight}; + SDL_RenderTexture(renderer.getSDLRenderer(), logoTex, nullptr, &dst); + currentY += displayHeight + spacingBetweenElements; + } + + FontAtlas* pixelFont = (FontAtlas*)app->m_assetManager->getFont("pixel_font"); + FontAtlas* fallbackFont = (FontAtlas*)app->m_assetManager->getFont("main_font"); + FontAtlas* loadingFont = pixelFont ? pixelFont : fallbackFont; + if (loadingFont) { + const std::string loadingText = "LOADING"; + int tW=0, tH=0; loadingFont->measure(loadingText, 1.0f, tW, tH); + float textX = (LOGICAL_W - (float)tW) * 0.5f; + loadingFont->draw(renderer.getSDLRenderer(), textX + contentOffsetX, currentY + contentOffsetY, loadingText, 1.0f, {255,204,0,255}); + } + + currentY += loadingTextHeight + barPaddingVertical; + + const int barW = 400, barH = 20; + const int bx = (LOGICAL_W - barW) / 2; + float loadingProgress = app->m_assetManager->getLoadingProgress(); + drawRectOriginal(bx - 3, currentY - 3, barW + 6, barH + 6, {68,68,80,255}); + drawRectOriginal(bx, currentY, barW, barH, {34,34,34,255}); + drawRectOriginal(bx, currentY, int(barW * loadingProgress), barH, {255,204,0,255}); + currentY += barH + spacingBetweenElements; + + if (loadingFont) { + int percentage = int(loadingProgress * 100); + char percentText[16]; + std::snprintf(percentText, sizeof(percentText), "%d%%", percentage); + std::string pStr(percentText); + int pW=0, pH=0; loadingFont->measure(pStr, 1.5f, pW, pH); + float percentX = (LOGICAL_W - (float)pW) * 0.5f; + loadingFont->draw(renderer.getSDLRenderer(), percentX + contentOffsetX, currentY + contentOffsetY, pStr, 1.5f, {255,204,0,255}); + } + + SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr); + SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f); +} + + +ApplicationManager::~ApplicationManager() { + if (m_initialized) { + shutdown(); + } +} + +bool ApplicationManager::initialize(int argc, char* argv[]) { + if (m_initialized) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "ApplicationManager already initialized"); + return true; + } + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Initializing ApplicationManager..."); + + // Initialize GlobalState + GlobalState::instance().initialize(); + + // Set initial logical dimensions + GlobalState::instance().updateLogicalDimensions(m_windowWidth, m_windowHeight); + + // Initialize SDL first + if (!initializeSDL()) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize SDL"); + return false; + } + + // Initialize managers + if (!initializeManagers()) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize managers"); + cleanupSDL(); + return false; + } + + // Register services for dependency injection + registerServices(); + + // Initialize game systems + if (!initializeGame()) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize game systems"); + cleanupManagers(); + cleanupSDL(); + return false; + } + + m_initialized = true; + m_running = true; + m_lastFrameTime = SDL_GetTicks(); + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "ApplicationManager initialized successfully"); + return true; +} + +void ApplicationManager::run() { + if (!m_initialized) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "ApplicationManager not initialized"); + return; + } + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Starting main application loop"); + traceFile("Main loop starting"); + + while (m_running) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, "Main loop iteration start: m_running=%d", m_running ? 1 : 0); + traceFile("Main loop iteration"); + // Calculate delta time + uint64_t currentTime = SDL_GetTicks(); + float deltaTime = (currentTime - m_lastFrameTime) / 1000.0f; + m_lastFrameTime = currentTime; + + // Limit delta time to prevent spiral of death + if (deltaTime > Config::Performance::MIN_FRAME_TIME) { + deltaTime = Config::Performance::MIN_FRAME_TIME; + } + + // Main loop phases + processEvents(); + + if (m_running) { + update(deltaTime); + traceFile("about to call render"); + render(); + } + } + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Main application loop ended"); +} + +void ApplicationManager::shutdown() { + if (!m_initialized) { + return; + } + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Shutting down ApplicationManager..."); + + m_running = false; + + // Stop audio systems before tearing down SDL to avoid aborts/asserts + Audio::instance().shutdown(); + SoundEffectManager::instance().shutdown(); + + // Cleanup in reverse order of initialization + cleanupManagers(); + cleanupSDL(); + + // Shutdown GlobalState last + GlobalState::instance().shutdown(); + + m_initialized = false; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "ApplicationManager shutdown complete"); +} + +bool ApplicationManager::initializeSDL() { + // Initialize SDL subsystems + int sdlResult = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO); + if (sdlResult < 0) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed: %s", SDL_GetError()); + return false; + } + + // Initialize SDL_ttf + int ttfResult = TTF_Init(); + if (ttfResult < 0) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "TTF_Init failed: %s", SDL_GetError()); + SDL_Quit(); + return false; + } + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SDL initialized successfully"); + return true; +} + +bool ApplicationManager::initializeManagers() { + // Create and initialize RenderManager + m_renderManager = std::make_unique(); + if (!m_renderManager->initialize(m_windowWidth, m_windowHeight, m_windowTitle)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize RenderManager"); + return false; + } + m_isFullscreen = m_renderManager->isFullscreen(); + + // Create InputManager + m_inputManager = std::make_unique(); + if (!m_inputManager) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create InputManager"); + return false; + } + + // Create and initialize AssetManager + m_assetManager = std::make_unique(); + if (!m_assetManager->initialize(m_renderManager->getSDLRenderer())) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize AssetManager"); + return false; + } + + // Ensure SoundEffectManager is initialized early so SFX loads work + SoundEffectManager::instance().init(); + + // Create StateManager (will be enhanced in next steps) + m_stateManager = std::make_unique(AppState::Loading); + + // Create and initialize starfields + m_starfield3D = std::make_unique(); + m_starfield3D->init(Config::Logical::WIDTH, Config::Logical::HEIGHT, 200); + + m_starfield = std::make_unique(); + m_starfield->init(Config::Logical::WIDTH, Config::Logical::HEIGHT, 50); + + // Register InputManager handlers to forward events to StateManager so + // state-specific event handlers receive SDL_Event objects just like main.cpp. + if (m_inputManager && m_stateManager) { + m_inputManager->registerKeyHandler([this](SDL_Scancode sc, bool pressed){ + if (!m_stateManager) return; + + bool consume = false; + + // Global hotkeys (handled across all states) + if (pressed) { + // While the help overlay is visible, swallow input so gameplay/menu doesn't react. + // Allow only help-toggle/close keys to pass through this global handler. + if (m_showHelpOverlay) { + if (sc == SDL_SCANCODE_ESCAPE) { + m_showHelpOverlay = false; + if (m_helpOverlayPausedGame && m_game) { + m_game->setPaused(false); + } + m_helpOverlayPausedGame = false; + } else if (sc == SDL_SCANCODE_F1) { + // Toggle off + m_showHelpOverlay = false; + if (m_helpOverlayPausedGame && m_game) { + m_game->setPaused(false); + } + m_helpOverlayPausedGame = false; + } + consume = true; + } + + // Toggle fullscreen on F, F11 or Alt+Enter (or Alt+KP_Enter) + if (sc == SDL_SCANCODE_F || sc == SDL_SCANCODE_F11 || + ((sc == SDL_SCANCODE_RETURN || sc == SDL_SCANCODE_RETURN2 || sc == SDL_SCANCODE_KP_ENTER) && + (SDL_GetModState() & SDL_KMOD_ALT))) { + if (m_renderManager) { + bool fs = m_renderManager->isFullscreen(); + m_renderManager->setFullscreen(!fs); + m_isFullscreen = m_renderManager->isFullscreen(); + } + consume = true; + } + + // M: Toggle/mute music; start playback if unmuting and not started yet + if (!consume && sc == SDL_SCANCODE_M) { + Audio::instance().toggleMute(); + m_musicEnabled = !m_musicEnabled; + if (m_musicEnabled && !m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) { + Audio::instance().shuffle(); + Audio::instance().start(); + m_musicStarted = true; + } + consume = true; + } + + // N: Skip to next song in the playlist (or restart menu track) + if (!consume && sc == SDL_SCANCODE_N) { + Audio::instance().skipToNextTrack(); + if (!m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) { + m_musicStarted = true; + m_musicEnabled = true; + } + consume = true; + } + + if (!consume && (sc == SDL_SCANCODE_F1)) { + AppState currentState = m_stateManager ? m_stateManager->getState() : AppState::Loading; + // F1 is global (except Loading). + if (currentState != AppState::Loading) { + m_showHelpOverlay = !m_showHelpOverlay; + if (currentState == AppState::Playing && m_game) { + if (m_showHelpOverlay) { + if (!m_game->isPaused()) { + m_game->setPaused(true); + m_helpOverlayPausedGame = true; + } else { + m_helpOverlayPausedGame = false; + } + } else if (m_helpOverlayPausedGame) { + m_game->setPaused(false); + m_helpOverlayPausedGame = false; + } + } else if (!m_showHelpOverlay) { + m_helpOverlayPausedGame = false; + } + } + consume = true; + } + } + + // Forward to current state unless consumed + if (!consume) { + SDL_Event ev{}; + ev.type = pressed ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + ev.key.scancode = sc; + ev.key.repeat = 0; + m_stateManager->handleEvent(ev); + } + }); + + m_inputManager->registerMouseButtonHandler([this](int button, bool pressed, float x, float y){ + if (!m_stateManager) return; + if (m_showHelpOverlay) return; + SDL_Event ev{}; + ev.type = pressed ? SDL_EVENT_MOUSE_BUTTON_DOWN : SDL_EVENT_MOUSE_BUTTON_UP; + ev.button.button = button; + ev.button.x = int(x); + ev.button.y = int(y); + m_stateManager->handleEvent(ev); + }); + + m_inputManager->registerMouseMotionHandler([this](float x, float y, float dx, float dy){ + if (!m_stateManager) return; + if (m_showHelpOverlay) return; + SDL_Event ev{}; + ev.type = SDL_EVENT_MOUSE_MOTION; + ev.motion.x = int(x); + ev.motion.y = int(y); + ev.motion.xrel = int(dx); + ev.motion.yrel = int(dy); + m_stateManager->handleEvent(ev); + }); + + m_inputManager->registerWindowEventHandler([this](const SDL_WindowEvent& we){ + // Handle window resize events for RenderManager + if (we.type == SDL_EVENT_WINDOW_RESIZED && m_renderManager) { + m_renderManager->handleWindowResize(we.data1, we.data2); + + // Update GlobalState logical dimensions when window resizes + GlobalState::instance().updateLogicalDimensions(we.data1, we.data2); + } + + // Forward all window events to StateManager + if (!m_stateManager) return; + SDL_Event ev{}; + ev.type = we.type; + ev.window = we; + m_stateManager->handleEvent(ev); + }); + + m_inputManager->registerQuitHandler([this](){ + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[QUIT] InputManager quit handler invoked - setting running=false"); + traceFile("ApplicationManager: quit handler -> m_running=false"); + m_running = false; + }); + } + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Managers initialized successfully"); + return true; +} + +void ApplicationManager::registerServices() { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registering services for dependency injection..."); + + // Register concrete implementations as interface singletons + if (m_renderManager) { + std::shared_ptr renderPtr(m_renderManager.get(), [](RenderManager*) { + // Custom deleter that does nothing since the unique_ptr manages lifetime + }); + m_serviceContainer.registerSingleton(renderPtr); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IRenderer service"); + } + + if (m_assetManager) { + std::shared_ptr assetPtr(m_assetManager.get(), [](AssetManager*) { + // Custom deleter that does nothing since the unique_ptr manages lifetime + }); + m_serviceContainer.registerSingleton(assetPtr); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IAssetLoader service"); + } + + if (m_inputManager) { + std::shared_ptr inputPtr(m_inputManager.get(), [](InputManager*) { + // Custom deleter that does nothing since the unique_ptr manages lifetime + }); + m_serviceContainer.registerSingleton(inputPtr); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IInputHandler service"); + } + + // Register Audio system singleton + auto& audioInstance = Audio::instance(); + auto audioPtr = std::shared_ptr