Compare commits
82 Commits
b0cec977a5
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 516aa16737 | |||
| 735e966608 | |||
| 68b35ea57b | |||
| 938988c876 | |||
| 03bdc82dc1 | |||
| 17cb64c9d4 | |||
| 6ef93e4c9c | |||
| e2dd768faf | |||
| 0b546ce25c | |||
| 45086e58d8 | |||
| b1f2033880 | |||
| 5fd3febd8e | |||
| 60d6a9e740 | |||
| e1921858ed | |||
| 14cb96345c | |||
| d28feb3276 | |||
| a7a3ae9055 | |||
| 5ec4bf926b | |||
| 0e04617968 | |||
| b450e2af21 | |||
| a65756f298 | |||
| dac312ef2b | |||
| 953d6af701 | |||
| c14e305a4a | |||
| fb036dede5 | |||
| 3c9dc0ff65 | |||
| d3ca238a51 | |||
| a729dc089e | |||
| 18463774e9 | |||
| 694243ac89 | |||
| 60ddc9ddd3 | |||
| 70946fc720 | |||
| fb82ac06d0 | |||
| 494f906435 | |||
| 50c869536d | |||
| 0b99911f5d | |||
| 33d5eedec8 | |||
| 744268fedd | |||
| 06aa63f548 | |||
| a9943ce8bf | |||
| b46af7ab1d | |||
| ab22d4c34f | |||
| e2d6ea64a4 | |||
| 322744c296 | |||
| cf3e897752 | |||
| 4efb60bb5b | |||
| afd7fdf18d | |||
| 5b9eb5f0e3 | |||
| 09c8d3c0ef | |||
| a6c2c78cb5 | |||
| 18c29fed1e | |||
| f3064e9dad | |||
| a1f16a7d94 | |||
| eb9822dac7 | |||
| 6c48af0bec | |||
| b69b090e45 | |||
| ad014e1de0 | |||
| 9a3c1a0688 | |||
| 970259e3d6 | |||
| 34447f0245 | |||
| fd29ae271e | |||
| 737dc71d8c | |||
| 8a4dc2771d | |||
| 212dd4c404 | |||
| a520de6c1f | |||
| 38dbc17ace | |||
| 783c12790d | |||
| 64fce596ce | |||
| adf418dff9 | |||
| fe0cd289e2 | |||
| 989b98002c | |||
| 0ab7121c5b | |||
| 492abc09bc | |||
| fe6c5e3c8a | |||
| 122de2b36f | |||
| a671825502 | |||
| cecf5cf68e | |||
| 3264672be0 | |||
| 29c1d6b745 | |||
| 81586aa768 | |||
| ec086b2cd4 | |||
| 8be2d68b98 |
168
.copilot-rules.md
Normal file
@ -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
|
||||
6
.github/copilot-instructions.md
vendored
@ -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.
|
||||
|
||||
36
.github/workflows/build.yml
vendored
@ -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 }}
|
||||
|
||||
6
.gitignore
vendored
@ -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/
|
||||
@ -18,6 +18,7 @@
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
settings.ini
|
||||
|
||||
# vcpkg
|
||||
/vcpkg_installed/
|
||||
@ -70,7 +71,4 @@ dist_package/
|
||||
# Local environment files (if any)
|
||||
.env
|
||||
|
||||
# Ignore local settings file
|
||||
settings.ini
|
||||
|
||||
# End of .gitignore
|
||||
|
||||
100
CMakeLists.txt
@ -15,7 +15,7 @@ if(DEFINED CMAKE_TOOLCHAIN_FILE)
|
||||
set(CMAKE_TOOLCHAIN_FILE "${CMAKE_TOOLCHAIN_FILE}" CACHE STRING "" FORCE)
|
||||
endif()
|
||||
|
||||
project(tetris_sdl3 LANGUAGES CXX)
|
||||
project(spacetris_sdl3 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@ -28,10 +28,14 @@ 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)
|
||||
|
||||
set(TETRIS_SOURCES
|
||||
src/main.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/state/StateManager.cpp
|
||||
# New core architecture classes
|
||||
@ -42,48 +46,76 @@ set(TETRIS_SOURCES
|
||||
src/core/Settings.cpp
|
||||
src/graphics/renderers/RenderManager.cpp
|
||||
src/persistence/Scores.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/audio/AudioManager.cpp
|
||||
src/renderer/SDLRenderer.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/resources/ResourceManager.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(tetris MACOSX_BUNDLE ${TETRIS_SOURCES} "${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(tetris PROPERTIES
|
||||
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(tetris MACOSX_BUNDLE ${TETRIS_SOURCES})
|
||||
set_target_properties(tetris PROPERTIES
|
||||
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(tetris ${TETRIS_SOURCES})
|
||||
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)
|
||||
@ -98,14 +130,14 @@ 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} $<TARGET_FILE_DIR:tetris>/favicon.ico
|
||||
add_custom_command(TARGET spacetris POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FAVICON_SRC} $<TARGET_FILE_DIR:spacetris>/favicon.ico
|
||||
COMMENT "Copy favicon.ico next to executable"
|
||||
)
|
||||
endif()
|
||||
@ -114,35 +146,42 @@ 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" "$<TARGET_FILE_DIR:tetris>/assets"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "$<TARGET_FILE_DIR:spacetris>/assets"
|
||||
)
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_SOURCE_DIR}/fonts")
|
||||
list(APPEND _mac_copy_commands
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/fonts" "$<TARGET_FILE_DIR:tetris>/fonts"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/fonts" "$<TARGET_FILE_DIR:spacetris>/fonts"
|
||||
)
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_SOURCE_DIR}/FreeSans.ttf")
|
||||
list(APPEND _mac_copy_commands
|
||||
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "$<TARGET_FILE_DIR:tetris>/FreeSans.ttf"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "$<TARGET_FILE_DIR:spacetris>/FreeSans.ttf"
|
||||
)
|
||||
endif()
|
||||
if(_mac_copy_commands)
|
||||
add_custom_command(TARGET tetris POST_BUILD
|
||||
add_custom_command(TARGET spacetris POST_BUILD
|
||||
${_mac_copy_commands}
|
||||
COMMENT "Copying game assets into macOS bundle"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
target_link_libraries(tetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json)
|
||||
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(tetris PRIVATE "-framework AudioToolbox" "-framework CoreFoundation")
|
||||
target_link_libraries(spacetris PRIVATE "-framework AudioToolbox" "-framework CoreFoundation")
|
||||
endif()
|
||||
|
||||
# Include production build configuration
|
||||
@ -153,22 +192,37 @@ 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()
|
||||
|
||||
# GoogleTest-based board unit tests
|
||||
find_package(GTest CONFIG REQUIRED)
|
||||
add_executable(test_board
|
||||
tests/test_board.cpp
|
||||
src/logic/Board.cpp
|
||||
)
|
||||
target_include_directories(test_board PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||
target_link_libraries(test_board PRIVATE GTest::gtest_main)
|
||||
add_test(NAME BoardTests COMMAND test_board)
|
||||
|
||||
if(EXISTS "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
||||
target_include_directories(test_board 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
|
||||
|
||||
760
CODE_ANALYSIS.md
@ -1,760 +0,0 @@
|
||||
# Tetris SDL3 - Code Analysis & Best Practices Review
|
||||
|
||||
**Generated:** 2025-12-03
|
||||
**Project:** Tetris Game (SDL3)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Executive Summary
|
||||
|
||||
Your Tetris project is **well-structured and follows many modern C++ best practices**. The codebase demonstrates:
|
||||
- ✅ Clean separation of concerns with a state-based architecture
|
||||
- ✅ Modern C++20 features and RAII patterns
|
||||
- ✅ Centralized configuration management
|
||||
- ✅ Proper dependency management via vcpkg
|
||||
- ✅ Good documentation and code organization
|
||||
|
||||
However, there are opportunities for improvement in areas like memory management, error handling, and code duplication.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Strengths
|
||||
|
||||
### 1. **Architecture & Design Patterns**
|
||||
- **State Pattern Implementation**: Clean state management with `MenuState`, `PlayingState`, `OptionsState`, `LevelSelectorState`, and `LoadingState`
|
||||
- **Separation of Concerns**: Game logic (`Game.cpp`), rendering (`GameRenderer`, `UIRenderer`), audio (`Audio`, `SoundEffect`), and persistence (`Scores`) are well-separated
|
||||
- **Centralized Configuration**: `Config.h` provides a single source of truth for constants, eliminating magic numbers
|
||||
- **Service Locator Pattern**: `StateContext` acts as a dependency injection container
|
||||
|
||||
### 2. **Modern C++ Practices**
|
||||
- **C++20 Standard**: Using modern features like `std::filesystem`, `std::jthread`
|
||||
- **RAII**: Proper resource management with smart pointers and automatic cleanup
|
||||
- **Type Safety**: Strong typing with enums (`PieceType`, `AppState`, `LevelBackgroundPhase`)
|
||||
- **Const Correctness**: Good use of `const` methods and references
|
||||
|
||||
### 3. **Code Organization**
|
||||
```
|
||||
src/
|
||||
├── audio/ # Audio system (music, sound effects)
|
||||
├── core/ # Core systems (state management, settings, global state)
|
||||
├── gameplay/ # Game logic (Tetris mechanics, effects)
|
||||
├── graphics/ # Rendering (UI, game renderer, effects)
|
||||
├── persistence/ # Score management
|
||||
├── states/ # State implementations
|
||||
└── utils/ # Utilities
|
||||
```
|
||||
This structure is logical and easy to navigate.
|
||||
|
||||
### 4. **Build System**
|
||||
- **CMake**: Modern CMake with proper target configuration
|
||||
- **vcpkg**: Excellent dependency management
|
||||
- **Cross-platform**: Support for Windows and macOS
|
||||
- **Testing**: Catch2 integration for unit tests
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Areas for Improvement
|
||||
|
||||
### 1. **Memory Management Issues**
|
||||
|
||||
#### **Problem: Raw Pointer Usage**
|
||||
**Location:** `MenuState.h`, `main.cpp`
|
||||
```cpp
|
||||
// MenuState.h (lines 17-21)
|
||||
SDL_Texture* playIcon = nullptr;
|
||||
SDL_Texture* levelIcon = nullptr;
|
||||
SDL_Texture* optionsIcon = nullptr;
|
||||
SDL_Texture* exitIcon = nullptr;
|
||||
```
|
||||
|
||||
**Issue:** Raw pointers to SDL resources without proper cleanup in all code paths.
|
||||
|
||||
**Recommendation:**
|
||||
```cpp
|
||||
// Create a smart pointer wrapper for SDL_Texture
|
||||
struct SDL_TextureDeleter {
|
||||
void operator()(SDL_Texture* tex) const {
|
||||
if (tex) SDL_DestroyTexture(tex);
|
||||
}
|
||||
};
|
||||
using SDL_TexturePtr = std::unique_ptr<SDL_Texture, SDL_TextureDeleter>;
|
||||
|
||||
// Usage in MenuState.h
|
||||
private:
|
||||
SDL_TexturePtr playIcon;
|
||||
SDL_TexturePtr levelIcon;
|
||||
SDL_TexturePtr optionsIcon;
|
||||
SDL_TexturePtr exitIcon;
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Automatic cleanup
|
||||
- Exception safety
|
||||
- No manual memory management
|
||||
- Clear ownership semantics
|
||||
|
||||
---
|
||||
|
||||
### 2. **Error Handling**
|
||||
|
||||
#### **Problem: Inconsistent Error Handling**
|
||||
**Location:** `main.cpp` (lines 86-114)
|
||||
```cpp
|
||||
static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) {
|
||||
if (!renderer) {
|
||||
return nullptr; // Silent failure
|
||||
}
|
||||
|
||||
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
||||
if (!surface) {
|
||||
SDL_LogError(...); // Logs but returns nullptr
|
||||
return nullptr;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
- Silent failures make debugging difficult
|
||||
- Callers must check for `nullptr` (easy to forget)
|
||||
- No way to distinguish between different error types
|
||||
|
||||
**Recommendation:**
|
||||
```cpp
|
||||
#include <expected> // C++23, or use tl::expected for C++20
|
||||
|
||||
struct TextureLoadError {
|
||||
std::string message;
|
||||
std::string path;
|
||||
};
|
||||
|
||||
std::expected<SDL_TexturePtr, TextureLoadError>
|
||||
loadTextureFromImage(SDL_Renderer* renderer, const std::string& path,
|
||||
int* outW = nullptr, int* outH = nullptr) {
|
||||
if (!renderer) {
|
||||
return std::unexpected(TextureLoadError{
|
||||
"Renderer is null", path
|
||||
});
|
||||
}
|
||||
|
||||
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
||||
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
||||
if (!surface) {
|
||||
return std::unexpected(TextureLoadError{
|
||||
std::string("Failed to load: ") + SDL_GetError(),
|
||||
resolvedPath
|
||||
});
|
||||
}
|
||||
|
||||
// ... success case
|
||||
return SDL_TexturePtr(texture);
|
||||
}
|
||||
|
||||
// Usage:
|
||||
auto result = loadTextureFromImage(renderer, "path.png");
|
||||
if (result) {
|
||||
// Use result.value()
|
||||
} else {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
||||
"Failed to load %s: %s",
|
||||
result.error().path.c_str(),
|
||||
result.error().message.c_str());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **Code Duplication**
|
||||
|
||||
#### **Problem: Repeated Patterns**
|
||||
**Location:** `MenuState.cpp`, `PlayingState.cpp`, `OptionsState.cpp`
|
||||
|
||||
Similar lambda patterns for exit popup handling:
|
||||
```cpp
|
||||
auto setExitSelection = [&](int value) {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = value;
|
||||
}
|
||||
};
|
||||
|
||||
auto getExitSelection = [&]() -> int {
|
||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||
};
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
Create a helper class in `StateContext`:
|
||||
```cpp
|
||||
// StateContext.h
|
||||
class ExitPopupHelper {
|
||||
public:
|
||||
ExitPopupHelper(int* selectedButton, bool* showPopup)
|
||||
: m_selectedButton(selectedButton), m_showPopup(showPopup) {}
|
||||
|
||||
void setSelection(int value) {
|
||||
if (m_selectedButton) *m_selectedButton = value;
|
||||
}
|
||||
|
||||
int getSelection() const {
|
||||
return m_selectedButton ? *m_selectedButton : 1;
|
||||
}
|
||||
|
||||
void show() {
|
||||
if (m_showPopup) *m_showPopup = true;
|
||||
}
|
||||
|
||||
void hide() {
|
||||
if (m_showPopup) *m_showPopup = false;
|
||||
}
|
||||
|
||||
bool isVisible() const {
|
||||
return m_showPopup && *m_showPopup;
|
||||
}
|
||||
|
||||
private:
|
||||
int* m_selectedButton;
|
||||
bool* m_showPopup;
|
||||
};
|
||||
|
||||
// Usage in states:
|
||||
ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup);
|
||||
exitPopup.setSelection(0);
|
||||
if (exitPopup.isVisible()) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **Magic Numbers**
|
||||
|
||||
#### **Problem: Some Magic Numbers Still Present**
|
||||
**Location:** `MenuState.cpp` (lines 269-273)
|
||||
```cpp
|
||||
float btnW = 200.0f; // Fixed width to match background buttons
|
||||
float btnH = 70.0f; // Fixed height to match background buttons
|
||||
float btnX = LOGICAL_W * 0.5f + contentOffsetX;
|
||||
float btnY = LOGICAL_H * 0.865f + contentOffsetY;
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
Add to `Config.h`:
|
||||
```cpp
|
||||
namespace Config::UI {
|
||||
constexpr float MENU_BUTTON_WIDTH = 200.0f;
|
||||
constexpr float MENU_BUTTON_HEIGHT = 70.0f;
|
||||
constexpr float MENU_BUTTON_Y_FRACTION = 0.865f;
|
||||
constexpr float MENU_BUTTON_SPACING = 210.0f;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. **File I/O for Debugging**
|
||||
|
||||
#### **Problem: Debug Logging to Files**
|
||||
**Location:** `MenuState.cpp` (lines 182-184, 195-203, etc.)
|
||||
```cpp
|
||||
FILE* f = fopen("tetris_trace.log", "a");
|
||||
if (f) {
|
||||
fprintf(f, "MenuState::render entry\n");
|
||||
fclose(f);
|
||||
}
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
- File handles not checked properly
|
||||
- No error handling
|
||||
- Performance overhead in production
|
||||
- Should use proper logging framework
|
||||
|
||||
**Recommendation:**
|
||||
```cpp
|
||||
// Create a simple logger utility
|
||||
// src/utils/Logger.h
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
|
||||
class Logger {
|
||||
public:
|
||||
enum class Level { TRACE, DEBUG, INFO, WARN, ERROR };
|
||||
|
||||
static Logger& instance();
|
||||
|
||||
void setLevel(Level level) { m_level = level; }
|
||||
void setFile(const std::string& path);
|
||||
|
||||
template<typename... Args>
|
||||
void trace(const char* fmt, Args... args) {
|
||||
log(Level::TRACE, fmt, args...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
void debug(const char* fmt, Args... args) {
|
||||
log(Level::DEBUG, fmt, args...);
|
||||
}
|
||||
|
||||
private:
|
||||
Logger() = default;
|
||||
|
||||
template<typename... Args>
|
||||
void log(Level level, const char* fmt, Args... args);
|
||||
|
||||
Level m_level = Level::INFO;
|
||||
std::ofstream m_file;
|
||||
std::mutex m_mutex;
|
||||
};
|
||||
|
||||
// Usage:
|
||||
#ifdef DEBUG
|
||||
Logger::instance().trace("MenuState::render entry");
|
||||
#endif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. **Const Correctness**
|
||||
|
||||
#### **Problem: Missing const in Some Places**
|
||||
**Location:** `StateContext` and various state methods
|
||||
|
||||
**Recommendation:**
|
||||
```cpp
|
||||
// State.h
|
||||
class State {
|
||||
public:
|
||||
virtual void render(SDL_Renderer* renderer, float logicalScale,
|
||||
SDL_Rect logicalVP) const = 0; // Add const
|
||||
// Render shouldn't modify state
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. **Thread Safety**
|
||||
|
||||
#### **Problem: Potential Race Conditions**
|
||||
**Location:** `Audio.cpp` - Background loading
|
||||
|
||||
**Current:**
|
||||
```cpp
|
||||
std::vector<AudioTrack> tracks;
|
||||
std::mutex tracksMutex;
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
- Document thread safety guarantees
|
||||
- Use `std::shared_mutex` for read-heavy operations
|
||||
- Consider using lock-free data structures for performance-critical paths
|
||||
|
||||
```cpp
|
||||
// Audio.h
|
||||
class Audio {
|
||||
private:
|
||||
std::vector<AudioTrack> tracks;
|
||||
mutable std::shared_mutex tracksMutex; // Allow concurrent reads
|
||||
|
||||
public:
|
||||
// Read operation - shared lock
|
||||
int getLoadedTrackCount() const {
|
||||
std::shared_lock lock(tracksMutex);
|
||||
return tracks.size();
|
||||
}
|
||||
|
||||
// Write operation - exclusive lock
|
||||
void addTrack(const std::string& path) {
|
||||
std::unique_lock lock(tracksMutex);
|
||||
tracks.push_back(loadTrack(path));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. **Testing Coverage**
|
||||
|
||||
#### **Current State:**
|
||||
Only one test file: `tests/GravityTests.cpp`
|
||||
|
||||
**Recommendation:**
|
||||
Add comprehensive tests:
|
||||
```
|
||||
tests/
|
||||
├── GravityTests.cpp ✅ Exists
|
||||
├── GameLogicTests.cpp ❌ Missing
|
||||
├── ScoreManagerTests.cpp ❌ Missing
|
||||
├── StateTransitionTests.cpp ❌ Missing
|
||||
└── AudioSystemTests.cpp ❌ Missing
|
||||
```
|
||||
|
||||
**Example Test Structure:**
|
||||
```cpp
|
||||
// tests/GameLogicTests.cpp
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "gameplay/core/Game.h"
|
||||
|
||||
TEST_CASE("Game initialization", "[game]") {
|
||||
Game game(0);
|
||||
|
||||
SECTION("Board starts empty") {
|
||||
const auto& board = game.boardRef();
|
||||
REQUIRE(std::all_of(board.begin(), board.end(),
|
||||
[](int cell) { return cell == 0; }));
|
||||
}
|
||||
|
||||
SECTION("Score starts at zero") {
|
||||
REQUIRE(game.score() == 0);
|
||||
REQUIRE(game.lines() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Piece rotation", "[game]") {
|
||||
Game game(0);
|
||||
|
||||
SECTION("Clockwise rotation") {
|
||||
auto initialRot = game.current().rot;
|
||||
game.rotate(1);
|
||||
REQUIRE(game.current().rot == (initialRot + 1) % 4);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Line clearing", "[game]") {
|
||||
Game game(0);
|
||||
|
||||
SECTION("Single line clear awards correct score") {
|
||||
// Setup: Fill bottom row except one cell
|
||||
// ... test implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. **Documentation**
|
||||
|
||||
#### **Current State:**
|
||||
- Good inline comments
|
||||
- Config.h has excellent documentation
|
||||
- Missing: API documentation, architecture overview
|
||||
|
||||
**Recommendation:**
|
||||
Add Doxygen-style comments:
|
||||
```cpp
|
||||
/**
|
||||
* @class Game
|
||||
* @brief Core Tetris game logic engine
|
||||
*
|
||||
* Manages the game board, piece spawning, collision detection,
|
||||
* line clearing, and scoring. This class is independent of
|
||||
* rendering and input handling.
|
||||
*
|
||||
* @note Thread-safe for read operations, but write operations
|
||||
* (move, rotate, etc.) should only be called from the
|
||||
* main game thread.
|
||||
*
|
||||
* Example usage:
|
||||
* @code
|
||||
* Game game(5); // Start at level 5
|
||||
* game.tickGravity(16.67); // Update for one frame
|
||||
* if (game.isGameOver()) {
|
||||
* // Handle game over
|
||||
* }
|
||||
* @endcode
|
||||
*/
|
||||
class Game {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Create `docs/ARCHITECTURE.md`:
|
||||
```markdown
|
||||
# Architecture Overview
|
||||
|
||||
## State Machine
|
||||
[Diagram of state transitions]
|
||||
|
||||
## Data Flow
|
||||
[Diagram showing how data flows through the system]
|
||||
|
||||
## Threading Model
|
||||
- Main thread: Rendering, input, game logic
|
||||
- Background thread: Audio loading
|
||||
- Audio callback thread: Audio mixing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. **Performance Considerations**
|
||||
|
||||
#### **Issue: Frequent String Allocations**
|
||||
**Location:** Various places using `std::string` for paths
|
||||
|
||||
**Recommendation:**
|
||||
```cpp
|
||||
// Use string_view for read-only string parameters
|
||||
#include <string_view>
|
||||
|
||||
SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer,
|
||||
std::string_view path, // Changed
|
||||
int* outW = nullptr,
|
||||
int* outH = nullptr);
|
||||
|
||||
// For compile-time strings, use constexpr
|
||||
namespace AssetPaths {
|
||||
constexpr std::string_view LOGO = "assets/images/logo.bmp";
|
||||
constexpr std::string_view BACKGROUND = "assets/images/main_background.bmp";
|
||||
}
|
||||
```
|
||||
|
||||
#### **Issue: Vector Reallocations**
|
||||
**Location:** `fireworks` vector in `main.cpp`
|
||||
|
||||
**Recommendation:**
|
||||
```cpp
|
||||
// Reserve capacity upfront
|
||||
fireworks.reserve(5); // Max 5 fireworks at once
|
||||
|
||||
// Or use a fixed-size container
|
||||
std::array<std::optional<TetrisFirework>, 5> fireworks;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Specific Recommendations by Priority
|
||||
|
||||
### **High Priority** (Do These First)
|
||||
|
||||
1. **Replace raw SDL pointers with smart pointers**
|
||||
- Impact: Prevents memory leaks
|
||||
- Effort: Medium
|
||||
- Files: `MenuState.h`, `main.cpp`, all state files
|
||||
|
||||
2. **Remove debug file I/O from production code**
|
||||
- Impact: Performance, code cleanliness
|
||||
- Effort: Low
|
||||
- Files: `MenuState.cpp`, `main.cpp`
|
||||
|
||||
3. **Add error handling to asset loading**
|
||||
- Impact: Better debugging, crash prevention
|
||||
- Effort: Medium
|
||||
- Files: `main.cpp`, `AssetManager.cpp`
|
||||
|
||||
### **Medium Priority**
|
||||
|
||||
4. **Extract common patterns into helper classes**
|
||||
- Impact: Code maintainability
|
||||
- Effort: Medium
|
||||
- Files: All state files
|
||||
|
||||
5. **Move remaining magic numbers to Config.h**
|
||||
- Impact: Maintainability
|
||||
- Effort: Low
|
||||
- Files: `MenuState.cpp`, `UIRenderer.cpp`
|
||||
|
||||
6. **Add comprehensive unit tests**
|
||||
- Impact: Code quality, regression prevention
|
||||
- Effort: High
|
||||
- Files: New test files
|
||||
|
||||
### **Low Priority** (Nice to Have)
|
||||
|
||||
7. **Add Doxygen documentation**
|
||||
- Impact: Developer onboarding
|
||||
- Effort: Medium
|
||||
|
||||
8. **Performance profiling and optimization**
|
||||
- Impact: Depends on current performance
|
||||
- Effort: Medium
|
||||
|
||||
9. **Consider using `std::expected` for error handling**
|
||||
- Impact: Better error handling
|
||||
- Effort: High (requires C++23 or external library)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Code Style Observations
|
||||
|
||||
### **Good Practices You're Already Following:**
|
||||
|
||||
✅ **Consistent naming conventions:**
|
||||
- Classes: `PascalCase` (e.g., `MenuState`, `GameRenderer`)
|
||||
- Functions: `camelCase` (e.g., `tickGravity`, `loadTexture`)
|
||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `LOGICAL_W`, `DAS_DELAY`)
|
||||
- Member variables: `camelCase` with `m_` prefix in some places
|
||||
|
||||
✅ **Header guards:** Using `#pragma once`
|
||||
|
||||
✅ **Forward declarations:** Minimizing include dependencies
|
||||
|
||||
✅ **RAII:** Resources tied to object lifetime
|
||||
|
||||
### **Minor Style Inconsistencies:**
|
||||
|
||||
❌ **Inconsistent member variable naming:**
|
||||
```cpp
|
||||
// Some classes use m_ prefix
|
||||
float m_masterVolume = 1.0f;
|
||||
|
||||
// Others don't
|
||||
int selectedButton = 0;
|
||||
```
|
||||
|
||||
**Recommendation:** Pick one style and stick to it. I suggest:
|
||||
```cpp
|
||||
// Private members: m_ prefix
|
||||
float m_masterVolume = 1.0f;
|
||||
int m_selectedButton = 0;
|
||||
|
||||
// Public members: no prefix (rare in good design)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Architecture Suggestions
|
||||
|
||||
### **Consider Implementing:**
|
||||
|
||||
1. **Event System**
|
||||
Instead of callbacks, use an event bus:
|
||||
```cpp
|
||||
// events/GameEvents.h
|
||||
struct LineClearedEvent {
|
||||
int linesCleared;
|
||||
int newScore;
|
||||
};
|
||||
|
||||
struct LevelUpEvent {
|
||||
int newLevel;
|
||||
};
|
||||
|
||||
// EventBus.h
|
||||
class EventBus {
|
||||
public:
|
||||
template<typename Event>
|
||||
void subscribe(std::function<void(const Event&)> handler);
|
||||
|
||||
template<typename Event>
|
||||
void publish(const Event& event);
|
||||
};
|
||||
|
||||
// Usage in Game.cpp
|
||||
eventBus.publish(LineClearedEvent{linesCleared, _score});
|
||||
|
||||
// Usage in Audio system
|
||||
eventBus.subscribe<LineClearedEvent>([](const auto& e) {
|
||||
playLineClearSound(e.linesCleared);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Component-Based UI**
|
||||
Extract UI components:
|
||||
```cpp
|
||||
class Button {
|
||||
public:
|
||||
void render(SDL_Renderer* renderer);
|
||||
bool isHovered(int mouseX, int mouseY) const;
|
||||
void onClick(std::function<void()> callback);
|
||||
};
|
||||
|
||||
class Panel {
|
||||
std::vector<std::unique_ptr<UIComponent>> children;
|
||||
};
|
||||
```
|
||||
|
||||
3. **Asset Manager**
|
||||
Centralize asset loading:
|
||||
```cpp
|
||||
class AssetManager {
|
||||
public:
|
||||
SDL_TexturePtr getTexture(std::string_view name);
|
||||
FontAtlas* getFont(std::string_view name);
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, SDL_TexturePtr> textures;
|
||||
std::unordered_map<std::string, std::unique_ptr<FontAtlas>> fonts;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Security Considerations
|
||||
|
||||
1. **File Path Validation**
|
||||
```cpp
|
||||
// AssetPath::resolveImagePath should validate paths
|
||||
// to prevent directory traversal attacks
|
||||
std::string resolveImagePath(std::string_view path) {
|
||||
// Reject paths with ".."
|
||||
if (path.find("..") != std::string_view::npos) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
||||
"Invalid path: %s", path.data());
|
||||
return "";
|
||||
}
|
||||
// ... rest of implementation
|
||||
}
|
||||
```
|
||||
|
||||
2. **Score File Tampering**
|
||||
Consider adding checksums to score files:
|
||||
```cpp
|
||||
// Scores.cpp
|
||||
void ScoreManager::save() const {
|
||||
nlohmann::json j;
|
||||
j["scores"] = scores;
|
||||
j["checksum"] = computeChecksum(scores);
|
||||
// ... save to file
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Metrics
|
||||
|
||||
Based on the codebase analysis:
|
||||
|
||||
| Metric | Value | Rating |
|
||||
|--------|-------|--------|
|
||||
| **Code Organization** | Excellent | ⭐⭐⭐⭐⭐ |
|
||||
| **Modern C++ Usage** | Very Good | ⭐⭐⭐⭐ |
|
||||
| **Error Handling** | Fair | ⭐⭐⭐ |
|
||||
| **Memory Safety** | Good | ⭐⭐⭐⭐ |
|
||||
| **Test Coverage** | Poor | ⭐ |
|
||||
| **Documentation** | Good | ⭐⭐⭐⭐ |
|
||||
| **Performance** | Good | ⭐⭐⭐⭐ |
|
||||
| **Maintainability** | Very Good | ⭐⭐⭐⭐ |
|
||||
|
||||
**Overall Score: 4/5 ⭐⭐⭐⭐**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Wins (Easy Improvements)
|
||||
|
||||
1. **Add `.clang-format` file** for consistent formatting
|
||||
2. **Create `CONTRIBUTING.md`** with coding guidelines
|
||||
3. **Add pre-commit hooks** for formatting and linting
|
||||
4. **Set up GitHub Actions** for CI/CD
|
||||
5. **Add `README.md`** with build instructions and screenshots
|
||||
|
||||
---
|
||||
|
||||
## 📚 Recommended Resources
|
||||
|
||||
- **Modern C++ Best Practices:** https://isocpp.github.io/CppCoreGuidelines/
|
||||
- **SDL3 Migration Guide:** https://wiki.libsdl.org/SDL3/README/migration
|
||||
- **Game Programming Patterns:** https://gameprogrammingpatterns.com/
|
||||
- **C++ Testing with Catch2:** https://github.com/catchorg/Catch2
|
||||
|
||||
---
|
||||
|
||||
## ✅ Conclusion
|
||||
|
||||
Your Tetris project demonstrates **strong software engineering practices** with a clean architecture, modern C++ usage, and good separation of concerns. The main areas for improvement are:
|
||||
|
||||
1. Enhanced error handling
|
||||
2. Increased test coverage
|
||||
3. Elimination of raw pointers
|
||||
4. Removal of debug code from production
|
||||
|
||||
With these improvements, this codebase would be **production-ready** and serve as an excellent example of modern C++ game development.
|
||||
|
||||
**Keep up the excellent work!** 🎮
|
||||
@ -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"
|
||||
|
||||
@ -1,363 +0,0 @@
|
||||
# Tetris SDL3 - Improvements Checklist
|
||||
|
||||
Quick reference for implementing the recommendations from CODE_ANALYSIS.md
|
||||
|
||||
---
|
||||
|
||||
## 🔴 High Priority (Critical)
|
||||
|
||||
### 1. Smart Pointer Wrapper for SDL Resources
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 2-3 hours
|
||||
**Impact:** Prevents memory leaks, improves safety
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `src/utils/SDLPointers.h` with smart pointer wrappers
|
||||
- [ ] Replace raw `SDL_Texture*` in `MenuState.h` (lines 17-21)
|
||||
- [ ] Replace raw `SDL_Texture*` in `PlayingState.h`
|
||||
- [ ] Update `main.cpp` texture loading
|
||||
- [ ] Test all states to ensure no regressions
|
||||
|
||||
**Code Template:**
|
||||
```cpp
|
||||
// src/utils/SDLPointers.h
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
#include <memory>
|
||||
|
||||
struct SDL_TextureDeleter {
|
||||
void operator()(SDL_Texture* tex) const {
|
||||
if (tex) SDL_DestroyTexture(tex);
|
||||
}
|
||||
};
|
||||
|
||||
struct SDL_SurfaceDeleter {
|
||||
void operator()(SDL_Surface* surf) const {
|
||||
if (surf) SDL_DestroySurface(surf);
|
||||
}
|
||||
};
|
||||
|
||||
using SDL_TexturePtr = std::unique_ptr<SDL_Texture, SDL_TextureDeleter>;
|
||||
using SDL_SurfacePtr = std::unique_ptr<SDL_Surface, SDL_SurfaceDeleter>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Remove Debug File I/O
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 30 minutes
|
||||
**Impact:** Performance, code cleanliness
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Remove or wrap `fopen("tetris_trace.log")` calls in `MenuState.cpp`
|
||||
- [ ] Remove or wrap similar calls in other files
|
||||
- [ ] Replace with SDL_LogTrace or conditional compilation
|
||||
- [ ] Delete `tetris_trace.log` from repository
|
||||
|
||||
**Files to Update:**
|
||||
- `src/states/MenuState.cpp` (lines 182-184, 195-203, 277-278, 335-337)
|
||||
- `src/main.cpp` (if any similar patterns exist)
|
||||
|
||||
**Replacement Pattern:**
|
||||
```cpp
|
||||
// Before:
|
||||
FILE* f = fopen("tetris_trace.log", "a");
|
||||
if (f) { fprintf(f, "MenuState::render entry\n"); fclose(f); }
|
||||
|
||||
// After:
|
||||
SDL_LogTrace(SDL_LOG_CATEGORY_APPLICATION, "MenuState::render entry");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Improve Error Handling in Asset Loading
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 2 hours
|
||||
**Impact:** Better debugging, prevents crashes
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Update `loadTextureFromImage` to return error information
|
||||
- [ ] Add validation for all asset loads in `main.cpp`
|
||||
- [ ] Create fallback assets for missing resources
|
||||
- [ ] Add startup asset validation
|
||||
|
||||
**Example:**
|
||||
```cpp
|
||||
struct AssetLoadResult {
|
||||
SDL_TexturePtr texture;
|
||||
std::string error;
|
||||
bool success;
|
||||
};
|
||||
|
||||
AssetLoadResult loadTextureFromImage(SDL_Renderer* renderer,
|
||||
const std::string& path);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Medium Priority (Important)
|
||||
|
||||
### 4. Extract Common Patterns
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 3-4 hours
|
||||
**Impact:** Reduces code duplication
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `ExitPopupHelper` class in `StateContext.h`
|
||||
- [ ] Update `MenuState.cpp` to use helper
|
||||
- [ ] Update `PlayingState.cpp` to use helper
|
||||
- [ ] Update `OptionsState.cpp` to use helper
|
||||
|
||||
---
|
||||
|
||||
### 5. Move Magic Numbers to Config.h
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 1 hour
|
||||
**Impact:** Maintainability
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Add menu button constants to `Config::UI`
|
||||
- [ ] Add rendering constants to appropriate namespace
|
||||
- [ ] Update `MenuState.cpp` to use config constants
|
||||
- [ ] Update `UIRenderer.cpp` to use config constants
|
||||
|
||||
**Constants to Add:**
|
||||
```cpp
|
||||
namespace Config::UI {
|
||||
constexpr float MENU_BUTTON_WIDTH = 200.0f;
|
||||
constexpr float MENU_BUTTON_HEIGHT = 70.0f;
|
||||
constexpr float MENU_BUTTON_Y_FRACTION = 0.865f;
|
||||
constexpr float MENU_BUTTON_SPACING = 210.0f;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Add Unit Tests
|
||||
**Status:** ⚠️ Minimal (only GravityTests)
|
||||
**Effort:** 8-10 hours
|
||||
**Impact:** Code quality, regression prevention
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `tests/GameLogicTests.cpp`
|
||||
- [ ] Test piece spawning
|
||||
- [ ] Test rotation
|
||||
- [ ] Test collision detection
|
||||
- [ ] Test line clearing
|
||||
- [ ] Test scoring
|
||||
- [ ] Create `tests/ScoreManagerTests.cpp`
|
||||
- [ ] Test score submission
|
||||
- [ ] Test high score detection
|
||||
- [ ] Test persistence
|
||||
- [ ] Create `tests/StateTransitionTests.cpp`
|
||||
- [ ] Test state transitions
|
||||
- [ ] Test state lifecycle (onEnter/onExit)
|
||||
- [ ] Update CMakeLists.txt to include new tests
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Low Priority (Nice to Have)
|
||||
|
||||
### 7. Add Doxygen Documentation
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 4-6 hours
|
||||
**Impact:** Developer onboarding
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `Doxyfile` configuration
|
||||
- [ ] Add class-level documentation to core classes
|
||||
- [ ] Add function-level documentation to public APIs
|
||||
- [ ] Generate HTML documentation
|
||||
- [ ] Add to build process
|
||||
|
||||
---
|
||||
|
||||
### 8. Performance Profiling
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 4-6 hours
|
||||
**Impact:** Depends on findings
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Profile with Visual Studio Profiler / Instruments
|
||||
- [ ] Identify hotspots
|
||||
- [ ] Optimize critical paths
|
||||
- [ ] Add performance benchmarks
|
||||
|
||||
---
|
||||
|
||||
### 9. Standardize Member Variable Naming
|
||||
**Status:** ⚠️ Inconsistent
|
||||
**Effort:** 2-3 hours
|
||||
**Impact:** Code consistency
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Decide on naming convention (recommend `m_` prefix for private members)
|
||||
- [ ] Update all class member variables
|
||||
- [ ] Update documentation to reflect convention
|
||||
|
||||
**Convention Recommendation:**
|
||||
```cpp
|
||||
class Example {
|
||||
public:
|
||||
int publicValue; // No prefix for public members
|
||||
|
||||
private:
|
||||
int m_privateValue; // m_ prefix for private members
|
||||
float m_memberVariable; // Consistent across all classes
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Code Quality Improvements
|
||||
|
||||
### 10. Add .clang-format
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 15 minutes
|
||||
**Impact:** Consistent formatting
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `.clang-format` file in project root
|
||||
- [ ] Run formatter on all source files
|
||||
- [ ] Add format check to CI/CD
|
||||
|
||||
**Suggested .clang-format:**
|
||||
```yaml
|
||||
BasedOnStyle: LLVM
|
||||
IndentWidth: 4
|
||||
ColumnLimit: 120
|
||||
PointerAlignment: Left
|
||||
AllowShortFunctionsOnASingleLine: Empty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11. Add README.md
|
||||
**Status:** ❌ Missing
|
||||
**Effort:** 1 hour
|
||||
**Impact:** Project documentation
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `README.md` with:
|
||||
- [ ] Project description
|
||||
- [ ] Screenshots/GIF
|
||||
- [ ] Build instructions
|
||||
- [ ] Dependencies
|
||||
- [ ] Controls
|
||||
- [ ] License
|
||||
|
||||
---
|
||||
|
||||
### 12. Set Up CI/CD
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 2-3 hours
|
||||
**Impact:** Automated testing
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `.github/workflows/build.yml`
|
||||
- [ ] Add Windows build job
|
||||
- [ ] Add macOS build job
|
||||
- [ ] Add test execution
|
||||
- [ ] Add artifact upload
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Refactoring Opportunities
|
||||
|
||||
### 13. Create Asset Manager
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 4-5 hours
|
||||
**Impact:** Better resource management
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `src/core/assets/AssetManager.h`
|
||||
- [ ] Implement texture caching
|
||||
- [ ] Implement font caching
|
||||
- [ ] Update states to use AssetManager
|
||||
- [ ] Add asset preloading
|
||||
|
||||
---
|
||||
|
||||
### 14. Implement Event System
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 6-8 hours
|
||||
**Impact:** Decoupling, flexibility
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `src/core/events/EventBus.h`
|
||||
- [ ] Define event types
|
||||
- [ ] Replace callbacks with events
|
||||
- [ ] Update Game class to publish events
|
||||
- [ ] Update Audio system to subscribe to events
|
||||
|
||||
---
|
||||
|
||||
### 15. Component-Based UI
|
||||
**Status:** ❌ Not Started
|
||||
**Effort:** 8-10 hours
|
||||
**Impact:** UI maintainability
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create `src/ui/components/Button.h`
|
||||
- [ ] Create `src/ui/components/Panel.h`
|
||||
- [ ] Create `src/ui/components/Label.h`
|
||||
- [ ] Refactor MenuState to use components
|
||||
- [ ] Refactor OptionsState to use components
|
||||
|
||||
---
|
||||
|
||||
## 📊 Progress Tracking
|
||||
|
||||
| Category | Total Items | Completed | In Progress | Not Started |
|
||||
|----------|-------------|-----------|-------------|-------------|
|
||||
| High Priority | 3 | 0 | 0 | 3 |
|
||||
| Medium Priority | 3 | 0 | 0 | 3 |
|
||||
| Low Priority | 3 | 0 | 0 | 3 |
|
||||
| Code Quality | 3 | 0 | 0 | 3 |
|
||||
| Refactoring | 3 | 0 | 0 | 3 |
|
||||
| **TOTAL** | **15** | **0** | **0** | **15** |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Suggested Implementation Order
|
||||
|
||||
### Week 1: Critical Fixes
|
||||
1. Remove debug file I/O (30 min)
|
||||
2. Smart pointer wrapper (2-3 hours)
|
||||
3. Improve error handling (2 hours)
|
||||
|
||||
### Week 2: Code Quality
|
||||
4. Move magic numbers to Config.h (1 hour)
|
||||
5. Extract common patterns (3-4 hours)
|
||||
6. Add .clang-format (15 min)
|
||||
7. Add README.md (1 hour)
|
||||
|
||||
### Week 3: Testing
|
||||
8. Add GameLogicTests (4 hours)
|
||||
9. Add ScoreManagerTests (2 hours)
|
||||
10. Add StateTransitionTests (2 hours)
|
||||
|
||||
### Week 4: Documentation & CI
|
||||
11. Set up CI/CD (2-3 hours)
|
||||
12. Add Doxygen documentation (4-6 hours)
|
||||
|
||||
### Future Iterations:
|
||||
13. Performance profiling
|
||||
14. Asset Manager
|
||||
15. Event System
|
||||
16. Component-Based UI
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Mark items as completed by changing ❌ to ✅
|
||||
- Update progress table as you complete items
|
||||
- Feel free to reorder based on your priorities
|
||||
- Some items can be done in parallel
|
||||
- Consider creating GitHub issues for tracking
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-03
|
||||
**Next Review:** After completing High Priority items
|
||||
@ -1,774 +0,0 @@
|
||||
# Quick Start: Implementing Top 3 Improvements
|
||||
|
||||
This guide provides complete, copy-paste ready code for the three most impactful improvements.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Improvement #1: Smart Pointer Wrapper for SDL Resources
|
||||
|
||||
### Step 1: Create the Utility Header
|
||||
|
||||
**File:** `src/utils/SDLPointers.h`
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* @file SDLPointers.h
|
||||
* @brief Smart pointer wrappers for SDL resources
|
||||
*
|
||||
* Provides RAII wrappers for SDL resources to prevent memory leaks
|
||||
* and ensure proper cleanup in all code paths.
|
||||
*/
|
||||
|
||||
namespace SDL {
|
||||
|
||||
/**
|
||||
* @brief Deleter for SDL_Texture
|
||||
*/
|
||||
struct TextureDeleter {
|
||||
void operator()(SDL_Texture* tex) const {
|
||||
if (tex) {
|
||||
SDL_DestroyTexture(tex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Deleter for SDL_Surface
|
||||
*/
|
||||
struct SurfaceDeleter {
|
||||
void operator()(SDL_Surface* surf) const {
|
||||
if (surf) {
|
||||
SDL_DestroySurface(surf);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Deleter for SDL_Renderer
|
||||
*/
|
||||
struct RendererDeleter {
|
||||
void operator()(SDL_Renderer* renderer) const {
|
||||
if (renderer) {
|
||||
SDL_DestroyRenderer(renderer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Deleter for SDL_Window
|
||||
*/
|
||||
struct WindowDeleter {
|
||||
void operator()(SDL_Window* window) const {
|
||||
if (window) {
|
||||
SDL_DestroyWindow(window);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Smart pointer for SDL_Texture
|
||||
*
|
||||
* Example usage:
|
||||
* @code
|
||||
* SDL::TexturePtr texture(SDL_CreateTexture(...));
|
||||
* if (!texture) {
|
||||
* // Handle error
|
||||
* }
|
||||
* // Automatic cleanup when texture goes out of scope
|
||||
* @endcode
|
||||
*/
|
||||
using TexturePtr = std::unique_ptr<SDL_Texture, TextureDeleter>;
|
||||
|
||||
/**
|
||||
* @brief Smart pointer for SDL_Surface
|
||||
*/
|
||||
using SurfacePtr = std::unique_ptr<SDL_Surface, SurfaceDeleter>;
|
||||
|
||||
/**
|
||||
* @brief Smart pointer for SDL_Renderer
|
||||
*/
|
||||
using RendererPtr = std::unique_ptr<SDL_Renderer, RendererDeleter>;
|
||||
|
||||
/**
|
||||
* @brief Smart pointer for SDL_Window
|
||||
*/
|
||||
using WindowPtr = std::unique_ptr<SDL_Window, WindowDeleter>;
|
||||
|
||||
} // namespace SDL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Update MenuState.h
|
||||
|
||||
**File:** `src/states/MenuState.h`
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
private:
|
||||
int selectedButton = 0;
|
||||
|
||||
// Button icons (optional - will use text if nullptr)
|
||||
SDL_Texture* playIcon = nullptr;
|
||||
SDL_Texture* levelIcon = nullptr;
|
||||
SDL_Texture* optionsIcon = nullptr;
|
||||
SDL_Texture* exitIcon = nullptr;
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
#include "../utils/SDLPointers.h" // Add this include
|
||||
|
||||
private:
|
||||
int selectedButton = 0;
|
||||
|
||||
// Button icons (optional - will use text if nullptr)
|
||||
SDL::TexturePtr playIcon;
|
||||
SDL::TexturePtr levelIcon;
|
||||
SDL::TexturePtr optionsIcon;
|
||||
SDL::TexturePtr exitIcon;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Update MenuState.cpp
|
||||
|
||||
**File:** `src/states/MenuState.cpp`
|
||||
|
||||
**Remove the manual cleanup from onExit:**
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
void MenuState::onExit() {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
}
|
||||
|
||||
// Clean up icon textures
|
||||
if (playIcon) { SDL_DestroyTexture(playIcon); playIcon = nullptr; }
|
||||
if (levelIcon) { SDL_DestroyTexture(levelIcon); levelIcon = nullptr; }
|
||||
if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; }
|
||||
if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; }
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
void MenuState::onExit() {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
}
|
||||
|
||||
// Icon textures are automatically cleaned up by smart pointers
|
||||
}
|
||||
```
|
||||
|
||||
**Update usage in render method:**
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
std::array<SDL_Texture*, 4> icons = {
|
||||
playIcon,
|
||||
levelIcon,
|
||||
optionsIcon,
|
||||
exitIcon
|
||||
};
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
std::array<SDL_Texture*, 4> icons = {
|
||||
playIcon.get(),
|
||||
levelIcon.get(),
|
||||
optionsIcon.get(),
|
||||
exitIcon.get()
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Update main.cpp Texture Loading
|
||||
|
||||
**File:** `src/main.cpp`
|
||||
|
||||
**Update the function signature and implementation:**
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) {
|
||||
if (!renderer) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
||||
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
||||
if (!surface) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (outW) { *outW = surface->w; }
|
||||
if (outH) { *outH = surface->h; }
|
||||
|
||||
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
|
||||
SDL_DestroySurface(surface);
|
||||
|
||||
if (!texture) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s", resolvedPath.c_str(), SDL_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (resolvedPath != path) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str());
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
#include "utils/SDLPointers.h" // Add at top of file
|
||||
|
||||
static SDL::TexturePtr loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) {
|
||||
if (!renderer) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Renderer is null");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
||||
SDL::SurfacePtr surface(IMG_Load(resolvedPath.c_str()));
|
||||
if (!surface) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s",
|
||||
path.c_str(), resolvedPath.c_str(), SDL_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (outW) { *outW = surface->w; }
|
||||
if (outH) { *outH = surface->h; }
|
||||
|
||||
SDL::TexturePtr texture(SDL_CreateTextureFromSurface(renderer, surface.get()));
|
||||
// surface is automatically destroyed here
|
||||
|
||||
if (!texture) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to create texture from %s: %s",
|
||||
resolvedPath.c_str(), SDL_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (resolvedPath != path) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loaded %s via %s", path.c_str(), resolvedPath.c_str());
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Improvement #2: Remove Debug File I/O
|
||||
|
||||
### Step 1: Replace with SDL Logging
|
||||
|
||||
**File:** `src/states/MenuState.cpp`
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
// Trace entry to persistent log for debugging abrupt exit/crash during render
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a");
|
||||
if (f) {
|
||||
fprintf(f, "MenuState::render entry\n");
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
// Use SDL's built-in logging (only in debug builds)
|
||||
#ifdef _DEBUG
|
||||
SDL_LogTrace(SDL_LOG_CATEGORY_APPLICATION, "MenuState::render entry");
|
||||
#endif
|
||||
```
|
||||
|
||||
**Or, if you want it always enabled but less verbose:**
|
||||
```cpp
|
||||
SDL_LogVerbose(SDL_LOG_CATEGORY_APPLICATION, "MenuState::render entry");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Create a Logging Utility (Optional, Better Approach)
|
||||
|
||||
**File:** `src/utils/Logger.h`
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
/**
|
||||
* @brief Centralized logging utility
|
||||
*
|
||||
* Wraps SDL logging with compile-time control over verbosity.
|
||||
*/
|
||||
namespace Logger {
|
||||
|
||||
#ifdef _DEBUG
|
||||
constexpr bool TRACE_ENABLED = true;
|
||||
#else
|
||||
constexpr bool TRACE_ENABLED = false;
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Log a trace message (only in debug builds)
|
||||
*/
|
||||
template<typename... Args>
|
||||
inline void trace(const char* fmt, Args... args) {
|
||||
if constexpr (TRACE_ENABLED) {
|
||||
SDL_LogTrace(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Log a debug message
|
||||
*/
|
||||
template<typename... Args>
|
||||
inline void debug(const char* fmt, Args... args) {
|
||||
SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Log an info message
|
||||
*/
|
||||
template<typename... Args>
|
||||
inline void info(const char* fmt, Args... args) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Log a warning message
|
||||
*/
|
||||
template<typename... Args>
|
||||
inline void warn(const char* fmt, Args... args) {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Log an error message
|
||||
*/
|
||||
template<typename... Args>
|
||||
inline void error(const char* fmt, Args... args) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, fmt, args...);
|
||||
}
|
||||
|
||||
} // namespace Logger
|
||||
```
|
||||
|
||||
**Usage in MenuState.cpp:**
|
||||
```cpp
|
||||
#include "../utils/Logger.h"
|
||||
|
||||
void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||
Logger::trace("MenuState::render entry");
|
||||
|
||||
// ... rest of render code
|
||||
|
||||
Logger::trace("MenuState::render exit");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Update All Files
|
||||
|
||||
**Files to update:**
|
||||
- `src/states/MenuState.cpp` (multiple locations)
|
||||
- `src/main.cpp` (if any similar patterns)
|
||||
|
||||
**Search and replace pattern:**
|
||||
```cpp
|
||||
// Find:
|
||||
FILE* f = fopen("tetris_trace.log", "a");
|
||||
if (f) {
|
||||
fprintf(f, ".*");
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
// Replace with:
|
||||
Logger::trace("...");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Improvement #3: Extract Common Patterns
|
||||
|
||||
### Step 1: Create ExitPopupHelper
|
||||
|
||||
**File:** `src/states/StateHelpers.h` (new file)
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* @file StateHelpers.h
|
||||
* @brief Helper classes for common state patterns
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Helper for managing exit confirmation popup
|
||||
*
|
||||
* Encapsulates the common pattern of showing/hiding an exit popup
|
||||
* and managing the selected button state.
|
||||
*
|
||||
* Example usage:
|
||||
* @code
|
||||
* ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup);
|
||||
*
|
||||
* if (exitPopup.isVisible()) {
|
||||
* exitPopup.setSelection(0); // Select YES
|
||||
* }
|
||||
*
|
||||
* if (exitPopup.isYesSelected()) {
|
||||
* // Handle exit
|
||||
* }
|
||||
* @endcode
|
||||
*/
|
||||
class ExitPopupHelper {
|
||||
public:
|
||||
/**
|
||||
* @brief Construct helper with pointers to state variables
|
||||
* @param selectedButton Pointer to selected button index (0=YES, 1=NO)
|
||||
* @param showPopup Pointer to popup visibility flag
|
||||
*/
|
||||
ExitPopupHelper(int* selectedButton, bool* showPopup)
|
||||
: m_selectedButton(selectedButton)
|
||||
, m_showPopup(showPopup)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @brief Set the selected button
|
||||
* @param value 0 for YES, 1 for NO
|
||||
*/
|
||||
void setSelection(int value) {
|
||||
if (m_selectedButton) {
|
||||
*m_selectedButton = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the currently selected button
|
||||
* @return 0 for YES, 1 for NO, defaults to 1 (NO) if pointer is null
|
||||
*/
|
||||
int getSelection() const {
|
||||
return m_selectedButton ? *m_selectedButton : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Select YES button
|
||||
*/
|
||||
void selectYes() {
|
||||
setSelection(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Select NO button
|
||||
*/
|
||||
void selectNo() {
|
||||
setSelection(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if YES is selected
|
||||
*/
|
||||
bool isYesSelected() const {
|
||||
return getSelection() == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if NO is selected
|
||||
*/
|
||||
bool isNoSelected() const {
|
||||
return getSelection() == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Show the popup
|
||||
*/
|
||||
void show() {
|
||||
if (m_showPopup) {
|
||||
*m_showPopup = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Hide the popup
|
||||
*/
|
||||
void hide() {
|
||||
if (m_showPopup) {
|
||||
*m_showPopup = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if popup is visible
|
||||
*/
|
||||
bool isVisible() const {
|
||||
return m_showPopup && *m_showPopup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Toggle between YES and NO
|
||||
*/
|
||||
void toggleSelection() {
|
||||
setSelection(isYesSelected() ? 1 : 0);
|
||||
}
|
||||
|
||||
private:
|
||||
int* m_selectedButton;
|
||||
bool* m_showPopup;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Update MenuState.cpp
|
||||
|
||||
**File:** `src/states/MenuState.cpp`
|
||||
|
||||
**Add include:**
|
||||
```cpp
|
||||
#include "StateHelpers.h"
|
||||
```
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
void MenuState::handleEvent(const SDL_Event& e) {
|
||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||
auto setExitSelection = [&](int value) {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = value;
|
||||
}
|
||||
};
|
||||
auto getExitSelection = [&]() -> int {
|
||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||
};
|
||||
auto isExitPromptVisible = [&]() -> bool {
|
||||
return ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup;
|
||||
};
|
||||
auto setExitPrompt = [&](bool visible) {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
*ctx.showExitConfirmPopup = visible;
|
||||
}
|
||||
};
|
||||
|
||||
if (isExitPromptVisible()) {
|
||||
switch (e.key.scancode) {
|
||||
case SDL_SCANCODE_LEFT:
|
||||
case SDL_SCANCODE_UP:
|
||||
setExitSelection(0);
|
||||
return;
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
case SDL_SCANCODE_DOWN:
|
||||
setExitSelection(1);
|
||||
return;
|
||||
case SDL_SCANCODE_RETURN:
|
||||
case SDL_SCANCODE_KP_ENTER:
|
||||
case SDL_SCANCODE_SPACE:
|
||||
if (getExitSelection() == 0) {
|
||||
setExitPrompt(false);
|
||||
if (ctx.requestQuit) {
|
||||
ctx.requestQuit();
|
||||
}
|
||||
} else {
|
||||
setExitPrompt(false);
|
||||
}
|
||||
return;
|
||||
case SDL_SCANCODE_ESCAPE:
|
||||
setExitPrompt(false);
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of code
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
void MenuState::handleEvent(const SDL_Event& e) {
|
||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||
ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup);
|
||||
|
||||
auto triggerPlay = [&]() {
|
||||
if (ctx.startPlayTransition) {
|
||||
ctx.startPlayTransition();
|
||||
} else if (ctx.stateManager) {
|
||||
ctx.stateManager->setState(AppState::Playing);
|
||||
}
|
||||
};
|
||||
|
||||
if (exitPopup.isVisible()) {
|
||||
switch (e.key.scancode) {
|
||||
case SDL_SCANCODE_LEFT:
|
||||
case SDL_SCANCODE_UP:
|
||||
exitPopup.selectYes();
|
||||
return;
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
case SDL_SCANCODE_DOWN:
|
||||
exitPopup.selectNo();
|
||||
return;
|
||||
case SDL_SCANCODE_RETURN:
|
||||
case SDL_SCANCODE_KP_ENTER:
|
||||
case SDL_SCANCODE_SPACE:
|
||||
if (exitPopup.isYesSelected()) {
|
||||
exitPopup.hide();
|
||||
if (ctx.requestQuit) {
|
||||
ctx.requestQuit();
|
||||
} else {
|
||||
SDL_Event quit{};
|
||||
quit.type = SDL_EVENT_QUIT;
|
||||
SDL_PushEvent(&quit);
|
||||
}
|
||||
} else {
|
||||
exitPopup.hide();
|
||||
}
|
||||
return;
|
||||
case SDL_SCANCODE_ESCAPE:
|
||||
exitPopup.hide();
|
||||
exitPopup.selectNo();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key.scancode) {
|
||||
case SDL_SCANCODE_LEFT:
|
||||
case SDL_SCANCODE_UP:
|
||||
{
|
||||
const int total = 4;
|
||||
selectedButton = (selectedButton + total - 1) % total;
|
||||
break;
|
||||
}
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
case SDL_SCANCODE_DOWN:
|
||||
{
|
||||
const int total = 4;
|
||||
selectedButton = (selectedButton + 1) % total;
|
||||
break;
|
||||
}
|
||||
case SDL_SCANCODE_RETURN:
|
||||
case SDL_SCANCODE_KP_ENTER:
|
||||
case SDL_SCANCODE_SPACE:
|
||||
if (!ctx.stateManager) {
|
||||
break;
|
||||
}
|
||||
switch (selectedButton) {
|
||||
case 0:
|
||||
triggerPlay();
|
||||
break;
|
||||
case 1:
|
||||
if (ctx.requestFadeTransition) {
|
||||
ctx.requestFadeTransition(AppState::LevelSelector);
|
||||
} else if (ctx.stateManager) {
|
||||
ctx.stateManager->setState(AppState::LevelSelector);
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (ctx.requestFadeTransition) {
|
||||
ctx.requestFadeTransition(AppState::Options);
|
||||
} else if (ctx.stateManager) {
|
||||
ctx.stateManager->setState(AppState::Options);
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
exitPopup.show();
|
||||
exitPopup.selectNo();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case SDL_SCANCODE_ESCAPE:
|
||||
exitPopup.show();
|
||||
exitPopup.selectNo();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Apply to Other States
|
||||
|
||||
Apply the same pattern to:
|
||||
- `src/states/PlayingState.cpp`
|
||||
- `src/states/OptionsState.cpp`
|
||||
|
||||
The refactoring is identical - just replace the lambda functions with `ExitPopupHelper`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Your Changes
|
||||
|
||||
After implementing these improvements:
|
||||
|
||||
1. **Build the project:**
|
||||
```powershell
|
||||
cd d:\Sites\Work\tetris
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
2. **Run the game:**
|
||||
```powershell
|
||||
.\build\Debug\tetris.exe
|
||||
```
|
||||
|
||||
3. **Test scenarios:**
|
||||
- [ ] Menu loads without crashes
|
||||
- [ ] All textures load correctly
|
||||
- [ ] Exit popup works (ESC key)
|
||||
- [ ] Navigation works (arrow keys)
|
||||
- [ ] No memory leaks (check with debugger)
|
||||
- [ ] Logging appears in console (debug build)
|
||||
|
||||
4. **Check for memory leaks:**
|
||||
- Run with Visual Studio debugger
|
||||
- Check Output window for memory leak reports
|
||||
- Should see no leaks from SDL textures
|
||||
|
||||
---
|
||||
|
||||
## 📊 Expected Impact
|
||||
|
||||
After implementing these three improvements:
|
||||
|
||||
| Metric | Before | After | Improvement |
|
||||
|--------|--------|-------|-------------|
|
||||
| **Memory Safety** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +67% |
|
||||
| **Code Clarity** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +25% |
|
||||
| **Maintainability** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +25% |
|
||||
| **Lines of Code** | 100% | ~95% | -5% |
|
||||
| **Potential Bugs** | Medium | Low | -50% |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Next Steps
|
||||
|
||||
After successfully implementing these improvements:
|
||||
|
||||
1. Review the full `CODE_ANALYSIS.md` for more recommendations
|
||||
2. Check `IMPROVEMENTS_CHECKLIST.md` for the complete task list
|
||||
3. Consider implementing the medium-priority items next
|
||||
4. Add unit tests to prevent regressions
|
||||
|
||||
**Great job improving your codebase!** 🚀
|
||||
34
README.md
Normal file
@ -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.
|
||||
@ -1,4 +1,4 @@
|
||||
# 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.
|
||||
|
||||
@ -20,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
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/asteroids_001.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
assets/images/blocks90px_002.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/images/blocks90px_003.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/images/cooperate_info.png
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
assets/images/hold_panel.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/main_screen_old.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
BIN
assets/music/asteroid-destroy.mp3
Normal file
BIN
assets/videos/spacetris_intro.mp4
Normal file
@ -1,5 +1,5 @@
|
||||
@echo off
|
||||
REM Build and run debug executable for the Tetris project
|
||||
REM Build and run debug executable for the Spacetris project
|
||||
SETLOCAL
|
||||
cd /d "%~dp0"
|
||||
cmake --build build-msvc --config Debug
|
||||
@ -7,5 +7,5 @@ if errorlevel 1 (
|
||||
echo Build failed.
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
"%~dp0build-msvc\Debug\tetris.exe"
|
||||
"%~dp0build-msvc\Debug\spacetris.exe"
|
||||
ENDLOCAL
|
||||
|
||||
@ -8,12 +8,140 @@ 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"
|
||||
$proc = Start-Process -FilePath cmake -ArgumentList '--build','build-msvc','--config','Debug' -NoNewWindow -Wait -PassThru
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Write-Error "Build failed with exit code $($proc.ExitCode)"
|
||||
exit $proc.ExitCode
|
||||
& 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) {
|
||||
@ -21,7 +149,7 @@ if ($NoRun) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$exePath = Join-Path $root "build-msvc\Debug\tetris.exe"
|
||||
$exePath = Join-Path $root "build-msvc\Debug\spacetris.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
Write-Error "Executable not found: $exePath"
|
||||
exit 1
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# macOS Production Build Script for the SDL3 Tetris project
|
||||
# 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="tetris"
|
||||
PROJECT_NAME="spacetris"
|
||||
BUILD_DIR="build-release"
|
||||
OUTPUT_DIR="dist"
|
||||
PACKAGE_DIR=""
|
||||
@ -67,7 +67,7 @@ parse_args() {
|
||||
}
|
||||
|
||||
configure_paths() {
|
||||
PACKAGE_DIR="${OUTPUT_DIR}/TetrisGame-mac"
|
||||
PACKAGE_DIR="${OUTPUT_DIR}/SpacetrisGame-mac"
|
||||
}
|
||||
|
||||
generate_icns_if_needed() {
|
||||
@ -246,15 +246,15 @@ create_launchers() {
|
||||
launch_command="open \"./${app_dir}\""
|
||||
fi
|
||||
|
||||
cat > "$PACKAGE_DIR/Launch-Tetris.command" <<EOF
|
||||
cat > "$PACKAGE_DIR/Launch-Spacetris.command" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
cd "\$(dirname \"\$0\")"
|
||||
${launch_command}
|
||||
EOF
|
||||
chmod +x "$PACKAGE_DIR/Launch-Tetris.command"
|
||||
chmod +x "$PACKAGE_DIR/Launch-Spacetris.command"
|
||||
|
||||
cat > "$PACKAGE_DIR/README-mac.txt" <<EOF
|
||||
Tetris SDL3 Game - macOS Release $VERSION
|
||||
Spacetris SDL3 Game - macOS Release $VERSION
|
||||
=========================================
|
||||
|
||||
Requirements:
|
||||
@ -262,12 +262,12 @@ Requirements:
|
||||
- GPU with Metal support
|
||||
|
||||
Installation:
|
||||
1. Unzip the archive anywhere (e.g., ~/Games/Tetris).
|
||||
1. Unzip the archive anywhere (e.g., ~/Games/Spacetris).
|
||||
2. Ensure the executable bit stays set (script already does this).
|
||||
3. Double-click Launch-Tetris.command or run the binary/app manually from Terminal or Finder.
|
||||
3. Double-click Launch-Spacetris.command or run the binary/app manually from Terminal or Finder.
|
||||
|
||||
Files:
|
||||
- tetris / Launch-Tetris.command (start the game)
|
||||
- spacetris / Launch-Spacetris.command (start the game)
|
||||
- assets/ (art, audio, fonts)
|
||||
- *.dylib from SDL3 and related dependencies
|
||||
- FreeSans.ttf font
|
||||
@ -298,7 +298,7 @@ validate_package() {
|
||||
|
||||
create_zip() {
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
local zip_name="TetrisGame-mac-${VERSION}.zip"
|
||||
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
|
||||
@ -349,7 +349,7 @@ create_dmg() {
|
||||
fi
|
||||
|
||||
local app_name="${APP_BUNDLE_PATH##*/}"
|
||||
local dmg_name="TetrisGame-mac-${VERSION}.dmg"
|
||||
local dmg_name="SpacetrisGame-mac-${VERSION}.dmg"
|
||||
local dmg_path="$OUTPUT_DIR/$dmg_name"
|
||||
|
||||
if [[ ! -f "scripts/create-dmg.sh" ]]; then
|
||||
|
||||
@ -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,12 +60,12 @@ 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...
|
||||
set "PackageDir=dist\TetrisGame"
|
||||
set "PackageDir=dist\SpacetrisGame"
|
||||
set "copiedDependencies=0"
|
||||
|
||||
call :CopyDependencyDir "build-release\vcpkg_installed\x64-windows\bin"
|
||||
@ -76,19 +76,19 @@ if "%copiedDependencies%"=="0" (
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@ -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"
|
||||
@ -184,7 +184,7 @@ Write-Info "Creating distribution files..."
|
||||
|
||||
# Create README
|
||||
$ReadmeContent = @"
|
||||
Tetris SDL3 Game - Release $Version
|
||||
Spacetris SDL3 Game - Release $Version
|
||||
=====================================
|
||||
|
||||
## System Requirements
|
||||
@ -194,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
|
||||
@ -206,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
|
||||
- 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
|
||||
@ -230,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..."
|
||||
@ -259,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
|
||||
@ -280,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 ""
|
||||
|
||||
287
challenge_mode.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Spacetris — Challenge Mode (Asteroids) Implementation Spec for VS Code AI Agent
|
||||
|
||||
> Goal: Implement/extend **CHALLENGE** gameplay in Spacetris (not a separate mode), based on 100 levels with **asteroid** prefilled blocks that must be destroyed to advance.
|
||||
|
||||
---
|
||||
|
||||
## 1) High-level Requirements
|
||||
|
||||
### Modes
|
||||
- Existing mode remains **ENDLESS**.
|
||||
- Add/extend **CHALLENGE** mode with **100 levels**.
|
||||
|
||||
### Core Challenge Loop
|
||||
- Each level starts with **prefilled obstacle blocks** called **Asteroids**.
|
||||
- **Level N** starts with **N asteroids** (placed increasingly higher as level increases).
|
||||
- Player advances to the next level when **ALL asteroids are destroyed**.
|
||||
- Gravity (and optionally lock pressure) increases per level.
|
||||
|
||||
### Asteroid concept
|
||||
Asteroids are special blocks placed into the grid at level start:
|
||||
- They are **not** player-controlled pieces.
|
||||
- They have **types** and **hit points** (how many times they must be cleared via line clears).
|
||||
|
||||
---
|
||||
|
||||
## 2) Asteroid Types & Rules
|
||||
|
||||
Define asteroid types and their behavior:
|
||||
|
||||
### A) Normal Asteroid
|
||||
- `hitsRemaining = 1`
|
||||
- Removed when its row is cleared once.
|
||||
- Never moves (no gravity).
|
||||
|
||||
### B) Armored Asteroid
|
||||
- `hitsRemaining = 2`
|
||||
- On first line clear that includes it: decrement hits and change to cracked visual state.
|
||||
- On second clear: removed.
|
||||
- Never moves (no gravity).
|
||||
|
||||
### C) Falling Asteroid
|
||||
- `hitsRemaining = 2`
|
||||
- On first clear: decrement hits, then **becomes gravity-enabled** (drops until resting).
|
||||
- On second clear: removed.
|
||||
|
||||
### D) Core Asteroid (late levels)
|
||||
- `hitsRemaining = 3`
|
||||
- On each clear: decrement hits and change visual state.
|
||||
- After first hit (or after any hit — choose consistent rule) it becomes gravity-enabled.
|
||||
- On final clear: removed (optionally trigger bigger VFX).
|
||||
|
||||
**Important:** These are all within the same CHALLENGE mode.
|
||||
|
||||
---
|
||||
|
||||
## 3) Level Progression Rules (100 Levels)
|
||||
|
||||
### Asteroid Count
|
||||
- `asteroidsToPlace = level` (Level 1 -> 1 asteroid, Level 2 -> 2 asteroids, …)
|
||||
- Recommendation for implementation safety:
|
||||
- If `level` becomes too large to place comfortably, still place `level` but distribute across more rows and allow overlaps only if empty.
|
||||
- If needed, implement a soft cap for placement attempts (avoid infinite loops). If cannot place all, place as many as possible and log/telemetry.
|
||||
|
||||
### Placement Height / Region
|
||||
- Early levels: place in bottom 2–4 rows.
|
||||
- Mid levels: bottom 6–10 rows.
|
||||
- Late levels: up to ~half board height.
|
||||
- Use a function to define a `minRow..maxRow` region based on `level`.
|
||||
|
||||
Example guidance:
|
||||
- `maxRow = boardHeight - 1`
|
||||
- `minRow = boardHeight - 1 - clamp(2 + level/3, 2, boardHeight/2)`
|
||||
|
||||
### Type Distribution by Level (suggested)
|
||||
- Levels 1–9: Normal only
|
||||
- Levels 10–19: add Armored (small %)
|
||||
- Levels 20–59: add Falling (increasing %)
|
||||
- Levels 60–100: add Core (increasing %)
|
||||
|
||||
---
|
||||
|
||||
## 4) Difficulty Scaling
|
||||
|
||||
### Gravity Speed Scaling
|
||||
Implement per-level gravity scale:
|
||||
- `gravity = baseGravity * (1.0f + level * 0.02f)` (tune)
|
||||
- Or use a curve/table.
|
||||
|
||||
Optional additional scaling:
|
||||
- Reduced lock delay slightly at higher levels
|
||||
- Slightly faster DAS/ARR (if implemented)
|
||||
|
||||
---
|
||||
|
||||
## 5) Win/Lose Conditions
|
||||
|
||||
### Level Completion
|
||||
- Level completes when: `asteroidsRemaining == 0`
|
||||
- Then:
|
||||
- Clear board (or keep board — choose one consistent behavior; recommended: **clear board** for clean progression).
|
||||
- Show short transition (optional).
|
||||
- Load next level, until level 100.
|
||||
- After level 100 completion: show completion screen + stats.
|
||||
|
||||
### Game Over
|
||||
- Standard 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
|
||||
|
||||
---
|
||||
@ -1,5 +0,0 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <iostream>
|
||||
int main() { std::cout << \
|
||||
SDL_EVENT_QUIT:
|
||||
\ << SDL_EVENT_QUIT << std::endl; return 0; }
|
||||
@ -7,11 +7,11 @@
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.example.tetris</string>
|
||||
<string>com.example.spacetris</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Tetris</string>
|
||||
<string>Spacetris</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@ -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,60 +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
|
||||
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/TetrisGame"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:tetris>" "${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
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/SpacetrisGame"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:${GAME_TARGET}>" "${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(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/TetrisGame/"
|
||||
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/TetrisGame/"
|
||||
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
|
||||
if(GAME_TARGET)
|
||||
if(APPLE)
|
||||
install(TARGETS tetris
|
||||
install(TARGETS ${GAME_TARGET}
|
||||
BUNDLE DESTINATION .
|
||||
COMPONENT Runtime
|
||||
)
|
||||
else()
|
||||
install(TARGETS tetris
|
||||
install(TARGETS ${GAME_TARGET}
|
||||
RUNTIME DESTINATION bin
|
||||
COMPONENT Runtime
|
||||
)
|
||||
endif()
|
||||
|
||||
install(DIRECTORY assets/
|
||||
DESTINATION share/tetris/assets
|
||||
DESTINATION share/${GAME_TARGET}/assets
|
||||
COMPONENT Runtime
|
||||
)
|
||||
|
||||
install(FILES FreeSans.ttf
|
||||
DESTINATION share/tetris
|
||||
DESTINATION share/${GAME_TARGET}
|
||||
COMPONENT Runtime
|
||||
)
|
||||
endif()
|
||||
|
||||
# CPack configuration for creating installers (commented out - requires LICENSE file)
|
||||
# set(CPACK_PACKAGE_NAME "Tetris")
|
||||
|
||||
174
cooperate_mode_plan.md
Normal file
@ -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.
|
||||
271
docs/ai/cooperate_network.md
Normal file
@ -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.
|
||||
@ -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"
|
||||
|
||||
0
scripts/check_braces.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Create a distributable DMG for the macOS Tetris app
|
||||
# Create a distributable DMG for the macOS Spacetris app
|
||||
# Usage: ./scripts/create-dmg.sh <app-bundle-path> <output-dmg>
|
||||
# Example: ./scripts/create-dmg.sh dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg
|
||||
# Example: ./scripts/create-dmg.sh dist/SpacetrisGame-mac/spacetris.app dist/SpacetrisGame.dmg
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <app-bundle-path> <output-dmg>"
|
||||
echo "Example: $0 dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg"
|
||||
echo "Example: $0 dist/SpacetrisGame-mac/spacetris.app dist/SpacetrisGame.dmg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
0
scripts/find_unmatched.ps1
Normal file
@ -14,7 +14,7 @@ SmoothScroll=1
|
||||
UpRotateClockwise=0
|
||||
|
||||
[Player]
|
||||
Name=PLAYER
|
||||
Name=GREGOR
|
||||
|
||||
[Debug]
|
||||
Enabled=1
|
||||
|
||||
153
src/app/AssetLoader.cpp
Normal file
@ -0,0 +1,153 @@
|
||||
#include "app/AssetLoader.h"
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <algorithm>
|
||||
#include "app/TextureLoader.h"
|
||||
|
||||
#include "utils/ImagePathResolver.h"
|
||||
#include <filesystem>
|
||||
|
||||
AssetLoader::AssetLoader() = default;
|
||||
|
||||
AssetLoader::~AssetLoader() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
void AssetLoader::init(SDL_Renderer* renderer) {
|
||||
m_renderer = renderer;
|
||||
}
|
||||
|
||||
void AssetLoader::shutdown() {
|
||||
// Destroy textures
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
for (auto &p : m_textures) {
|
||||
if (p.second) SDL_DestroyTexture(p.second);
|
||||
}
|
||||
m_textures.clear();
|
||||
}
|
||||
|
||||
// Clear queue and errors
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_queueMutex);
|
||||
m_queue.clear();
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_errorsMutex);
|
||||
m_errors.clear();
|
||||
}
|
||||
|
||||
m_totalTasks = 0;
|
||||
m_loadedTasks = 0;
|
||||
m_renderer = nullptr;
|
||||
}
|
||||
|
||||
void AssetLoader::setResourceManager(resources::ResourceManager* mgr) {
|
||||
m_resourceManager = mgr;
|
||||
}
|
||||
|
||||
void AssetLoader::setBasePath(const std::string& basePath) {
|
||||
m_basePath = basePath;
|
||||
}
|
||||
|
||||
void AssetLoader::queueTexture(const std::string& path) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_queueMutex);
|
||||
m_queue.push_back(path);
|
||||
}
|
||||
m_totalTasks.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool AssetLoader::performStep() {
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_queueMutex);
|
||||
if (m_queue.empty()) return true;
|
||||
path = m_queue.front();
|
||||
m_queue.erase(m_queue.begin());
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_currentLoadingMutex);
|
||||
m_currentLoading = path;
|
||||
}
|
||||
|
||||
std::string fullPath = m_basePath.empty() ? path : (m_basePath + "/" + path);
|
||||
|
||||
// Diagnostic: resolve path and check file existence
|
||||
const std::string resolved = AssetPath::resolveImagePath(path);
|
||||
bool exists = false;
|
||||
try { if (!resolved.empty()) exists = std::filesystem::exists(std::filesystem::u8path(resolved)); } catch (...) { exists = false; }
|
||||
|
||||
// Use TextureLoader to centralize loading and ResourceManager caching
|
||||
TextureLoader loader(m_loadedTasks, m_currentLoading, m_currentLoadingMutex, m_errors, m_errorsMutex);
|
||||
loader.setResourceManager(m_resourceManager);
|
||||
// Pass the original queued path (not the full resolved path) so caching keys stay consistent
|
||||
SDL_Texture* tex = loader.loadFromImage(m_renderer, path);
|
||||
if (!tex) {
|
||||
// errors have been recorded by TextureLoader
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
auto& slot = m_textures[path];
|
||||
if (slot && slot != tex) {
|
||||
SDL_DestroyTexture(slot);
|
||||
}
|
||||
slot = tex;
|
||||
}
|
||||
|
||||
m_loadedTasks.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_currentLoadingMutex);
|
||||
m_currentLoading.clear();
|
||||
}
|
||||
|
||||
// Return true when no more queued tasks
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_queueMutex);
|
||||
return m_queue.empty();
|
||||
}
|
||||
}
|
||||
|
||||
void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) {
|
||||
if (!texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// register in local map and resource manager
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
auto& slot = m_textures[path];
|
||||
if (slot && slot != texture) {
|
||||
SDL_DestroyTexture(slot);
|
||||
}
|
||||
slot = texture;
|
||||
if (m_resourceManager) {
|
||||
std::shared_ptr<void> sp(texture, [](void* t){ SDL_DestroyTexture(static_cast<SDL_Texture*>(t)); });
|
||||
m_resourceManager->put(path, sp);
|
||||
}
|
||||
}
|
||||
|
||||
float AssetLoader::getProgress() const {
|
||||
int total = m_totalTasks.load(std::memory_order_relaxed);
|
||||
if (total <= 0) return 1.0f;
|
||||
int loaded = m_loadedTasks.load(std::memory_order_relaxed);
|
||||
return static_cast<float>(loaded) / static_cast<float>(total);
|
||||
}
|
||||
|
||||
std::vector<std::string> AssetLoader::getAndClearErrors() {
|
||||
std::lock_guard<std::mutex> lk(m_errorsMutex);
|
||||
std::vector<std::string> out = m_errors;
|
||||
m_errors.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
SDL_Texture* AssetLoader::getTexture(const std::string& path) const {
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
auto it = m_textures.find(path);
|
||||
if (it == m_textures.end()) return nullptr;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::string AssetLoader::getCurrentLoading() const {
|
||||
std::lock_guard<std::mutex> lk(m_currentLoadingMutex);
|
||||
return m_currentLoading;
|
||||
}
|
||||
71
src/app/AssetLoader.h
Normal file
@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <unordered_map>
|
||||
#include "../resources/ResourceManager.h"
|
||||
|
||||
// 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);
|
||||
void setResourceManager(resources::ResourceManager* mgr);
|
||||
|
||||
// Queue a texture path (relative to base path) for loading.
|
||||
void queueTexture(const std::string& path);
|
||||
|
||||
// Perform a single loading step (load one queued asset).
|
||||
// Returns true when all queued tasks are complete, false otherwise.
|
||||
bool performStep();
|
||||
|
||||
// Progress in [0,1]. If no tasks, returns 1.0f.
|
||||
float getProgress() const;
|
||||
|
||||
// Retrieve and clear accumulated error messages.
|
||||
std::vector<std::string> getAndClearErrors();
|
||||
|
||||
// Get a loaded texture (or nullptr if not loaded).
|
||||
SDL_Texture* getTexture(const std::string& path) const;
|
||||
|
||||
// Adopt an externally-created texture so AssetLoader owns its lifetime.
|
||||
// If a texture is already registered for this path, it will be replaced.
|
||||
void adoptTexture(const std::string& path, SDL_Texture* texture);
|
||||
|
||||
// Return currently-loading path (empty when idle).
|
||||
std::string getCurrentLoading() const;
|
||||
|
||||
private:
|
||||
SDL_Renderer* m_renderer = nullptr;
|
||||
std::string m_basePath;
|
||||
resources::ResourceManager* m_resourceManager = nullptr;
|
||||
|
||||
// queued paths (simple FIFO)
|
||||
std::vector<std::string> m_queue;
|
||||
mutable std::mutex m_queueMutex;
|
||||
|
||||
std::unordered_map<std::string, SDL_Texture*> m_textures;
|
||||
mutable std::mutex m_texturesMutex;
|
||||
|
||||
std::vector<std::string> m_errors;
|
||||
mutable std::mutex m_errorsMutex;
|
||||
|
||||
std::atomic<int> m_totalTasks{0};
|
||||
std::atomic<int> m_loadedTasks{0};
|
||||
|
||||
std::string m_currentLoading;
|
||||
mutable std::mutex m_currentLoadingMutex;
|
||||
};
|
||||
165
src/app/BackgroundManager.cpp
Normal file
@ -0,0 +1,165 @@
|
||||
#include "app/BackgroundManager.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include "utils/ImagePathResolver.h"
|
||||
|
||||
struct BackgroundManager::Impl {
|
||||
enum class Phase { Idle, ZoomOut, ZoomIn };
|
||||
SDL_Texture* currentTex = nullptr;
|
||||
SDL_Texture* nextTex = nullptr;
|
||||
int currentLevel = -1;
|
||||
int queuedLevel = -1;
|
||||
float phaseElapsedMs = 0.0f;
|
||||
float phaseDurationMs = 0.0f;
|
||||
float fadeDurationMs = 1200.0f;
|
||||
Phase phase = Phase::Idle;
|
||||
};
|
||||
|
||||
static float getPhaseDurationMs(const BackgroundManager::Impl& fader, BackgroundManager::Impl::Phase ph) {
|
||||
const float total = std::max(1200.0f, fader.fadeDurationMs);
|
||||
switch (ph) {
|
||||
case BackgroundManager::Impl::Phase::ZoomOut: return total * 0.45f;
|
||||
case BackgroundManager::Impl::Phase::ZoomIn: return total * 0.45f;
|
||||
default: return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
static void destroyTex(SDL_Texture*& t) {
|
||||
if (t) { SDL_DestroyTexture(t); t = nullptr; }
|
||||
}
|
||||
|
||||
BackgroundManager::BackgroundManager() : impl(new Impl()) {}
|
||||
BackgroundManager::~BackgroundManager() { reset(); delete impl; impl = nullptr; }
|
||||
|
||||
bool BackgroundManager::queueLevelBackground(SDL_Renderer* renderer, int level) {
|
||||
if (!renderer) return false;
|
||||
level = std::clamp(level, 0, 32);
|
||||
if (impl->currentLevel == level || impl->queuedLevel == level) return true;
|
||||
|
||||
char bgPath[256];
|
||||
std::snprintf(bgPath, sizeof(bgPath), "assets/images/levels/level%d.jpg", level);
|
||||
const std::string resolved = AssetPath::resolveImagePath(bgPath);
|
||||
|
||||
SDL_Surface* s = IMG_Load(resolved.c_str());
|
||||
if (!s) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Background load failed: %s (%s)", bgPath, resolved.c_str());
|
||||
return false;
|
||||
}
|
||||
SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer, s);
|
||||
SDL_DestroySurface(s);
|
||||
if (!tex) {
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "CreateTexture failed for %s", resolved.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
destroyTex(impl->nextTex);
|
||||
impl->nextTex = tex;
|
||||
impl->queuedLevel = level;
|
||||
|
||||
if (!impl->currentTex) {
|
||||
impl->currentTex = impl->nextTex;
|
||||
impl->currentLevel = impl->queuedLevel;
|
||||
impl->nextTex = nullptr;
|
||||
impl->queuedLevel = -1;
|
||||
impl->phase = Impl::Phase::Idle;
|
||||
impl->phaseElapsedMs = 0.0f;
|
||||
impl->phaseDurationMs = 0.0f;
|
||||
} else if (impl->phase == Impl::Phase::Idle) {
|
||||
impl->phase = Impl::Phase::ZoomOut;
|
||||
impl->phaseDurationMs = getPhaseDurationMs(*impl, impl->phase);
|
||||
impl->phaseElapsedMs = 0.0f;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BackgroundManager::update(float frameMs) {
|
||||
if (impl->phase == Impl::Phase::Idle) return;
|
||||
if (!impl->currentTex && !impl->nextTex) { impl->phase = Impl::Phase::Idle; return; }
|
||||
|
||||
impl->phaseElapsedMs += frameMs;
|
||||
if (impl->phaseElapsedMs < std::max(1.0f, impl->phaseDurationMs)) return;
|
||||
|
||||
if (impl->phase == Impl::Phase::ZoomOut) {
|
||||
if (impl->nextTex) {
|
||||
destroyTex(impl->currentTex);
|
||||
impl->currentTex = impl->nextTex;
|
||||
impl->currentLevel = impl->queuedLevel;
|
||||
impl->nextTex = nullptr;
|
||||
impl->queuedLevel = -1;
|
||||
}
|
||||
impl->phase = Impl::Phase::ZoomIn;
|
||||
impl->phaseDurationMs = getPhaseDurationMs(*impl, impl->phase);
|
||||
impl->phaseElapsedMs = 0.0f;
|
||||
} else if (impl->phase == Impl::Phase::ZoomIn) {
|
||||
impl->phase = Impl::Phase::Idle;
|
||||
impl->phaseElapsedMs = 0.0f;
|
||||
impl->phaseDurationMs = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
static void renderDynamic(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float baseScale, float motionClockMs, float alphaMul) {
|
||||
if (!renderer || !tex) return;
|
||||
const float seconds = motionClockMs * 0.001f;
|
||||
const float wobble = std::max(0.4f, baseScale + std::sin(seconds * 0.07f) * 0.02f + std::sin(seconds * 0.23f) * 0.01f);
|
||||
const float rotation = std::sin(seconds * 0.035f) * 1.25f;
|
||||
const float panX = std::sin(seconds * 0.11f) * winW * 0.02f;
|
||||
const float panY = std::cos(seconds * 0.09f) * winH * 0.015f;
|
||||
SDL_FRect dest{ (winW - winW * wobble) * 0.5f + panX, (winH - winH * wobble) * 0.5f + panY, winW * wobble, winH * wobble };
|
||||
SDL_FPoint center{dest.w * 0.5f, dest.h * 0.5f};
|
||||
Uint8 alpha = static_cast<Uint8>(std::clamp(alphaMul, 0.0f, 1.0f) * 255.0f);
|
||||
SDL_SetTextureAlphaMod(tex, alpha);
|
||||
SDL_RenderTextureRotated(renderer, tex, nullptr, &dest, rotation, ¢er, SDL_FLIP_NONE);
|
||||
SDL_SetTextureAlphaMod(tex, 255);
|
||||
}
|
||||
|
||||
void BackgroundManager::render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs) {
|
||||
if (!renderer) return;
|
||||
SDL_FRect fullRect{0.f,0.f,(float)winW,(float)winH};
|
||||
float duration = std::max(1.0f, impl->phaseDurationMs);
|
||||
float progress = (impl->phase == Impl::Phase::Idle) ? 0.0f : std::clamp(impl->phaseElapsedMs / duration, 0.0f, 1.0f);
|
||||
const float seconds = motionClockMs * 0.001f;
|
||||
|
||||
if (impl->phase == Impl::Phase::ZoomOut) {
|
||||
float scale = 1.0f + progress * 0.15f;
|
||||
if (impl->currentTex) {
|
||||
renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, (1.0f - progress * 0.4f));
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 0,0,0, Uint8(progress * 200.0f));
|
||||
SDL_RenderFillRect(renderer, &fullRect);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
} else if (impl->phase == Impl::Phase::ZoomIn) {
|
||||
float scale = 1.10f - progress * 0.10f;
|
||||
Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f);
|
||||
if (impl->currentTex) {
|
||||
renderDynamic(renderer, impl->currentTex, winW, winH, scale, motionClockMs, alpha / 255.0f);
|
||||
}
|
||||
} else {
|
||||
if (impl->currentTex) {
|
||||
renderDynamic(renderer, impl->currentTex, winW, winH, 1.02f, motionClockMs, 1.0f);
|
||||
float pulse = 0.35f + 0.25f * (0.5f + 0.5f * std::sin(seconds * 0.5f));
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 5,12,28, Uint8(pulse * 90.0f));
|
||||
SDL_RenderFillRect(renderer, &fullRect);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||
} else if (impl->nextTex) {
|
||||
renderDynamic(renderer, impl->nextTex, winW, winH, 1.02f, motionClockMs, 1.0f);
|
||||
} else {
|
||||
SDL_SetRenderDrawColor(renderer, 0,0,0,255);
|
||||
SDL_RenderFillRect(renderer, &fullRect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BackgroundManager::reset() {
|
||||
destroyTex(impl->currentTex);
|
||||
destroyTex(impl->nextTex);
|
||||
impl->currentLevel = -1;
|
||||
impl->queuedLevel = -1;
|
||||
impl->phaseElapsedMs = 0.0f;
|
||||
impl->phaseDurationMs = 0.0f;
|
||||
impl->phase = Impl::Phase::Idle;
|
||||
}
|
||||
18
src/app/BackgroundManager.h
Normal file
@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
class BackgroundManager {
|
||||
public:
|
||||
BackgroundManager();
|
||||
~BackgroundManager();
|
||||
|
||||
bool queueLevelBackground(SDL_Renderer* renderer, int level);
|
||||
void update(float frameMs);
|
||||
void render(SDL_Renderer* renderer, int winW, int winH, float motionClockMs);
|
||||
void reset();
|
||||
|
||||
struct Impl;
|
||||
|
||||
private:
|
||||
Impl* impl;
|
||||
};
|
||||
150
src/app/Fireworks.cpp
Normal file
@ -0,0 +1,150 @@
|
||||
#include "app/Fireworks.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <vector>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
struct BlockParticle {
|
||||
float x{}, y{}, vx{}, vy{}, size{}, alpha{}, decay{}, wobblePhase{}, wobbleSpeed{}, coreHeat{};
|
||||
BlockParticle(float sx, float sy) : x(sx), y(sy) {
|
||||
const float spreadDeg = 35.0f;
|
||||
const float angleDeg = -90.0f + spreadDeg * ((rand() % 200) / 100.0f - 1.0f);
|
||||
const float angleRad = angleDeg * 3.1415926f / 180.0f;
|
||||
float speed = 1.3f + (rand() % 220) / 80.0f;
|
||||
vx = std::cos(angleRad) * speed * 0.55f;
|
||||
vy = std::sin(angleRad) * speed;
|
||||
size = 6.0f + (rand() % 40) / 10.0f;
|
||||
alpha = 1.0f;
|
||||
decay = 0.0095f + (rand() % 180) / 12000.0f;
|
||||
wobblePhase = (rand() % 628) / 100.0f;
|
||||
wobbleSpeed = 0.08f + (rand() % 60) / 600.0f;
|
||||
coreHeat = 0.65f + (rand() % 35) / 100.0f;
|
||||
}
|
||||
bool update() {
|
||||
vx *= 0.992f;
|
||||
vy = vy * 0.985f - 0.015f;
|
||||
x += vx;
|
||||
y += vy;
|
||||
wobblePhase += wobbleSpeed;
|
||||
x += std::sin(wobblePhase) * 0.12f;
|
||||
alpha -= decay;
|
||||
size = std::max(1.8f, size - 0.03f);
|
||||
coreHeat = std::max(0.0f, coreHeat - decay * 0.6f);
|
||||
return alpha > 0.03f;
|
||||
}
|
||||
};
|
||||
|
||||
struct TetrisFirework {
|
||||
std::vector<BlockParticle> particles;
|
||||
TetrisFirework(float x, float y) {
|
||||
int particleCount = 30 + rand() % 25;
|
||||
particles.reserve(particleCount);
|
||||
for (int i=0;i<particleCount;++i) particles.emplace_back(x,y);
|
||||
}
|
||||
bool update() {
|
||||
for (auto it = particles.begin(); it != particles.end();) {
|
||||
if (!it->update()) it = particles.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
return !particles.empty();
|
||||
}
|
||||
};
|
||||
|
||||
static std::vector<TetrisFirework> fireworks;
|
||||
static double logoAnimCounter = 0.0;
|
||||
static int hoveredButton = -1;
|
||||
|
||||
static SDL_Color blendFireColor(float heat, float alphaScale, Uint8 minG, Uint8 minB) {
|
||||
heat = std::clamp(heat, 0.0f, 1.0f);
|
||||
Uint8 r = 255;
|
||||
Uint8 g = static_cast<Uint8>(std::clamp(120.0f + heat * (255.0f - 120.0f), float(minG), 255.0f));
|
||||
Uint8 b = static_cast<Uint8>(std::clamp(40.0f + (1.0f - heat) * 60.0f, float(minB), 255.0f));
|
||||
Uint8 a = static_cast<Uint8>(std::clamp(alphaScale * 255.0f, 0.0f, 255.0f));
|
||||
return SDL_Color{r,g,b,a};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace AppFireworks {
|
||||
void update(double frameMs) {
|
||||
if (fireworks.size() < 5 && (rand() % 100) < 2) {
|
||||
float x = 1200.0f * 0.55f + float(rand() % int(1200.0f * 0.35f));
|
||||
float y = 1000.0f * 0.80f + float(rand() % int(1000.0f * 0.15f));
|
||||
fireworks.emplace_back(x,y);
|
||||
}
|
||||
for (auto it = fireworks.begin(); it != fireworks.end();) {
|
||||
if (!it->update()) it = fireworks.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
}
|
||||
|
||||
void draw(SDL_Renderer* renderer, SDL_Texture*) {
|
||||
if (!renderer) return;
|
||||
SDL_BlendMode previousBlend = SDL_BLENDMODE_NONE;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &previousBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
static constexpr int quadIdx[6] = {0,1,2,2,1,3};
|
||||
|
||||
auto makeV = [](float px, float py, SDL_Color c){
|
||||
SDL_Vertex v{};
|
||||
v.position.x = px;
|
||||
v.position.y = py;
|
||||
v.color = SDL_FColor{ c.r/255.0f, c.g/255.0f, c.b/255.0f, c.a/255.0f };
|
||||
return v;
|
||||
};
|
||||
|
||||
for (auto& f : fireworks) {
|
||||
for (auto& p : f.particles) {
|
||||
const float heat = std::clamp(p.alpha * 1.25f + p.coreHeat * 0.5f, 0.0f, 1.0f);
|
||||
SDL_Color glow = blendFireColor(0.45f + heat * 0.55f, p.alpha * 0.55f, 100, 40);
|
||||
SDL_Color tailBase = blendFireColor(heat * 0.75f, p.alpha * 0.5f, 70, 25);
|
||||
SDL_Color tailTip = blendFireColor(heat * 0.35f, p.alpha * 0.2f, 40, 15);
|
||||
SDL_Color core = blendFireColor(heat, std::min(1.0f, p.alpha * 1.1f), 150, 80);
|
||||
|
||||
float velLen = std::sqrt(p.vx*p.vx + p.vy*p.vy);
|
||||
SDL_FPoint dir = velLen > 0.001f ? SDL_FPoint{p.vx/velLen,p.vy/velLen} : SDL_FPoint{0.0f,-1.0f};
|
||||
SDL_FPoint perp{-dir.y, dir.x};
|
||||
const float baseW = std::max(0.8f, p.size * 0.55f);
|
||||
const float tipW = baseW * 0.35f;
|
||||
const float tailLen = p.size * (3.0f + (1.0f - p.alpha) * 1.8f);
|
||||
|
||||
SDL_FPoint base{p.x,p.y};
|
||||
SDL_FPoint tip{p.x + dir.x*tailLen, p.y + dir.y*tailLen};
|
||||
|
||||
SDL_Vertex tail[4];
|
||||
tail[0] = makeV(base.x + perp.x * baseW, base.y + perp.y * baseW, tailBase);
|
||||
tail[1] = makeV(base.x - perp.x * baseW, base.y - perp.y * baseW, tailBase);
|
||||
tail[2] = makeV(tip.x + perp.x * tipW, tip.y + perp.y * tipW, tailTip);
|
||||
tail[3] = makeV(tip.x - perp.x * tipW, tip.y - perp.y * tipW, tailTip);
|
||||
SDL_RenderGeometry(renderer, nullptr, tail, 4, quadIdx, 6);
|
||||
|
||||
const float glowAlong = p.size * 0.95f;
|
||||
const float glowAcross = p.size * 0.6f;
|
||||
SDL_Vertex glowV[4];
|
||||
glowV[0] = makeV(base.x + dir.x * glowAlong, base.y + dir.y * glowAlong, glow);
|
||||
glowV[1] = makeV(base.x - dir.x * glowAlong, base.y - dir.y * glowAlong, glow);
|
||||
glowV[2] = makeV(base.x + perp.x * glowAcross, base.y + perp.y * glowAcross, glow);
|
||||
glowV[3] = makeV(base.x - perp.x * glowAcross, base.y - perp.y * glowAcross, glow);
|
||||
SDL_RenderGeometry(renderer, nullptr, glowV, 4, quadIdx, 6);
|
||||
|
||||
const float coreW = p.size * 0.35f;
|
||||
const float coreH = p.size * 0.9f;
|
||||
SDL_Vertex coreV[4];
|
||||
coreV[0] = makeV(base.x + perp.x * coreW, base.y + perp.y * coreW, core);
|
||||
coreV[1] = makeV(base.x - perp.x * coreW, base.y - perp.y * coreW, core);
|
||||
coreV[2] = makeV(base.x + dir.x * coreH, base.y + dir.y * coreH, core);
|
||||
coreV[3] = makeV(base.x - dir.x * coreH, base.y - dir.y * coreH, core);
|
||||
SDL_RenderGeometry(renderer, nullptr, coreV, 4, quadIdx, 6);
|
||||
}
|
||||
}
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, previousBlend);
|
||||
}
|
||||
|
||||
double getLogoAnimCounter() { return logoAnimCounter; }
|
||||
int getHoveredButton() { return hoveredButton; }
|
||||
void spawn(float x, float y) {
|
||||
fireworks.emplace_back(x, y);
|
||||
}
|
||||
} // namespace AppFireworks
|
||||
10
src/app/Fireworks.h
Normal file
@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace AppFireworks {
|
||||
void draw(SDL_Renderer* renderer, SDL_Texture* tex);
|
||||
void update(double frameMs);
|
||||
double getLogoAnimCounter();
|
||||
int getHoveredButton();
|
||||
void spawn(float x, float y);
|
||||
}
|
||||
2766
src/app/TetrisApp.cpp
Normal file
29
src/app/TetrisApp.h
Normal file
@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
// TetrisApp is the top-level application orchestrator.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - SDL/TTF init + shutdown
|
||||
// - Asset/music loading + loading screen
|
||||
// - Main loop + state transitions
|
||||
//
|
||||
// It uses a PIMPL to keep `TetrisApp.h` light (faster builds) and to avoid leaking
|
||||
// SDL-heavy includes into every translation unit.
|
||||
class TetrisApp {
|
||||
public:
|
||||
TetrisApp();
|
||||
~TetrisApp();
|
||||
|
||||
TetrisApp(const TetrisApp&) = delete;
|
||||
TetrisApp& operator=(const TetrisApp&) = delete;
|
||||
|
||||
// Runs the application until exit is requested.
|
||||
// Returns a non-zero exit code on initialization failure.
|
||||
int run();
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
115
src/app/TextureLoader.cpp
Normal file
@ -0,0 +1,115 @@
|
||||
#include "app/TextureLoader.h"
|
||||
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "utils/ImagePathResolver.h"
|
||||
|
||||
TextureLoader::TextureLoader(
|
||||
std::atomic<int>& loadedTasks,
|
||||
std::string& currentLoadingFile,
|
||||
std::mutex& currentLoadingMutex,
|
||||
std::vector<std::string>& assetLoadErrors,
|
||||
std::mutex& assetLoadErrorsMutex)
|
||||
: loadedTasks_(loadedTasks)
|
||||
, currentLoadingFile_(currentLoadingFile)
|
||||
, currentLoadingMutex_(currentLoadingMutex)
|
||||
, assetLoadErrors_(assetLoadErrors)
|
||||
, assetLoadErrorsMutex_(assetLoadErrorsMutex)
|
||||
{
|
||||
}
|
||||
|
||||
void TextureLoader::setCurrentLoadingFile(const std::string& filename) {
|
||||
std::lock_guard<std::mutex> lk(currentLoadingMutex_);
|
||||
currentLoadingFile_ = filename;
|
||||
}
|
||||
|
||||
void TextureLoader::clearCurrentLoadingFile() {
|
||||
std::lock_guard<std::mutex> lk(currentLoadingMutex_);
|
||||
currentLoadingFile_.clear();
|
||||
}
|
||||
|
||||
void TextureLoader::recordAssetLoadError(const std::string& message) {
|
||||
std::lock_guard<std::mutex> lk(assetLoadErrorsMutex_);
|
||||
assetLoadErrors_.emplace_back(message);
|
||||
}
|
||||
|
||||
SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW, int* outH) {
|
||||
if (!renderer) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
||||
setCurrentLoadingFile(resolvedPath.empty() ? path : resolvedPath);
|
||||
|
||||
// Check filesystem existence for diagnostics (no console log)
|
||||
bool fileExists = false;
|
||||
try { if (!resolvedPath.empty()) fileExists = std::filesystem::exists(std::filesystem::u8path(resolvedPath)); } catch (...) { fileExists = false; }
|
||||
// If resource manager provided, check cache first using the original asset key (path)
|
||||
if (resourceManager_) {
|
||||
if (auto sp = resourceManager_->get<SDL_Texture>(path)) {
|
||||
clearCurrentLoadingFile();
|
||||
loadedTasks_.fetch_add(1);
|
||||
return sp.get();
|
||||
}
|
||||
}
|
||||
|
||||
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) exists=%s: %s", path.c_str(), resolvedPath.c_str(), fileExists ? "yes" : "no", SDL_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (outW) {
|
||||
*outW = surface->w;
|
||||
}
|
||||
if (outH) {
|
||||
*outH = surface->h;
|
||||
}
|
||||
|
||||
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
|
||||
// surface size preserved in outW/outH; no console log
|
||||
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;
|
||||
}
|
||||
|
||||
// No texture-size console diagnostics here
|
||||
|
||||
// cache in resource manager if present
|
||||
if (resourceManager_) {
|
||||
std::shared_ptr<void> sp(texture, [](void* t){ SDL_DestroyTexture(static_cast<SDL_Texture*>(t)); });
|
||||
// store under original asset key (path) so callers using logical asset names find them
|
||||
resourceManager_->put(path, sp);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
36
src/app/TextureLoader.h
Normal file
@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../resources/ResourceManager.h"
|
||||
|
||||
class TextureLoader {
|
||||
public:
|
||||
TextureLoader(
|
||||
std::atomic<int>& loadedTasks,
|
||||
std::string& currentLoadingFile,
|
||||
std::mutex& currentLoadingMutex,
|
||||
std::vector<std::string>& assetLoadErrors,
|
||||
std::mutex& assetLoadErrorsMutex);
|
||||
|
||||
void setResourceManager(resources::ResourceManager* mgr) { resourceManager_ = mgr; }
|
||||
|
||||
SDL_Texture* loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr);
|
||||
|
||||
private:
|
||||
std::atomic<int>& loadedTasks_;
|
||||
std::string& currentLoadingFile_;
|
||||
std::mutex& currentLoadingMutex_;
|
||||
std::vector<std::string>& assetLoadErrors_;
|
||||
std::mutex& assetLoadErrorsMutex_;
|
||||
|
||||
void setCurrentLoadingFile(const std::string& filename);
|
||||
void clearCurrentLoadingFile();
|
||||
void recordAssetLoadError(const std::string& message);
|
||||
|
||||
resources::ResourceManager* resourceManager_ = nullptr;
|
||||
};
|
||||
@ -118,6 +118,7 @@ static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int
|
||||
outCh = static_cast<int>(clientFormat.mChannelsPerFrame);
|
||||
return !outPCM.empty();
|
||||
}
|
||||
|
||||
#else
|
||||
static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int& outRate, int& outCh){
|
||||
(void)outPCM; (void)outRate; (void)outCh; (void)path;
|
||||
@ -137,6 +138,11 @@ 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());
|
||||
@ -179,6 +185,8 @@ void Audio::skipToNextTrack(){
|
||||
void Audio::toggleMute(){ muted=!muted; }
|
||||
void Audio::setMuted(bool m){ muted=m; }
|
||||
|
||||
bool Audio::isMuted() const { return muted; }
|
||||
|
||||
void Audio::nextTrack(){
|
||||
if(tracks.empty()) { current = -1; return; }
|
||||
// Try every track once to find a decodable one
|
||||
|
||||
@ -32,29 +32,27 @@ public:
|
||||
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
|
||||
void startBackgroundLoading(); // start background thread for loading
|
||||
void waitForLoadingComplete(); // wait for all tracks to finish loading
|
||||
bool isLoadingComplete() const; // check if background loading is done
|
||||
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();
|
||||
// Additional IAudioSystem methods (forwarded to concrete implementation)
|
||||
bool init() override;
|
||||
void shutdown() override;
|
||||
void addTrack(const std::string& path) override;
|
||||
void addTrackAsync(const std::string& path) override;
|
||||
void startBackgroundLoading() override;
|
||||
bool isLoadingComplete() const override;
|
||||
int getLoadedTrackCount() const override;
|
||||
void start() override;
|
||||
void skipToNextTrack() override;
|
||||
void shuffle() override;
|
||||
void toggleMute() override;
|
||||
bool isMuted() const override;
|
||||
void setMuted(bool m);
|
||||
bool isMuted() const { return muted; }
|
||||
void setMenuTrack(const std::string& path) override;
|
||||
void playMenuMusic() override;
|
||||
void playGameMusic() override;
|
||||
void playSfx(const std::vector<int16_t>& pcm, int channels, int rate, float volume) override;
|
||||
|
||||
// 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<int16_t>& pcm, int channels, int rate, float volume);
|
||||
void shutdown();
|
||||
// Existing Audio class helper methods
|
||||
void waitForLoadingComplete(); // wait for all tracks to finish loading
|
||||
private:
|
||||
Audio()=default; ~Audio()=default; Audio(const Audio&)=delete; Audio& operator=(const Audio&)=delete;
|
||||
static void SDLCALL streamCallback(void* userdata, SDL_AudioStream* stream, int additional, int total);
|
||||
|
||||
15
src/audio/AudioManager.cpp
Normal file
@ -0,0 +1,15 @@
|
||||
#include "AudioManager.h"
|
||||
#include "Audio.h"
|
||||
|
||||
static IAudioSystem* g_audioSystem = nullptr;
|
||||
|
||||
IAudioSystem* AudioManager::get() {
|
||||
if (!g_audioSystem) {
|
||||
g_audioSystem = &Audio::instance();
|
||||
}
|
||||
return g_audioSystem;
|
||||
}
|
||||
|
||||
void AudioManager::set(IAudioSystem* sys) {
|
||||
g_audioSystem = sys;
|
||||
}
|
||||
11
src/audio/AudioManager.h
Normal file
@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "../core/interfaces/IAudioSystem.h"
|
||||
|
||||
class AudioManager {
|
||||
public:
|
||||
// Get the currently registered audio system (may return Audio::instance())
|
||||
static IAudioSystem* get();
|
||||
// Replace the audio system (for tests or different backends)
|
||||
static void set(IAudioSystem* sys);
|
||||
};
|
||||
@ -2,6 +2,7 @@
|
||||
#include "SoundEffect.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/AudioManager.h"
|
||||
#include <cstdio>
|
||||
#include <algorithm>
|
||||
#include <random>
|
||||
@ -46,7 +47,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;
|
||||
}
|
||||
|
||||
@ -56,8 +56,6 @@ void SoundEffect::play(float volume) {
|
||||
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));
|
||||
@ -96,7 +94,9 @@ void SimpleAudioPlayer::playSound(const std::vector<int16_t>& pcmData, int chann
|
||||
return;
|
||||
}
|
||||
// Route through shared Audio mixer so SFX always play over music
|
||||
Audio::instance().playSfx(pcmData, channels, sampleRate, volume);
|
||||
if (auto sys = AudioManager::get()) {
|
||||
sys->playSfx(pcmData, channels, sampleRate, volume);
|
||||
}
|
||||
}
|
||||
|
||||
bool SoundEffect::loadWAV(const std::string& filePath) {
|
||||
|
||||
@ -14,7 +14,7 @@ namespace Config {
|
||||
namespace Window {
|
||||
constexpr int DEFAULT_WIDTH = 1200;
|
||||
constexpr int DEFAULT_HEIGHT = 1000;
|
||||
constexpr const char* DEFAULT_TITLE = "Tetris (SDL3)";
|
||||
constexpr const char* DEFAULT_TITLE = "SpaceTris (SDL3)";
|
||||
constexpr bool DEFAULT_VSYNC = true;
|
||||
}
|
||||
|
||||
@ -130,7 +130,7 @@ namespace Config {
|
||||
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/blocks90px_001.bmp";
|
||||
constexpr const char* BLOCKS_BMP = "assets/images/2.png";
|
||||
}
|
||||
|
||||
// Audio settings
|
||||
|
||||
@ -21,7 +21,11 @@ std::string Settings::getSettingsPath() {
|
||||
bool Settings::load() {
|
||||
std::ifstream file(getSettingsPath());
|
||||
if (!file.is_open()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,8 @@ private:
|
||||
Settings& operator=(const Settings&) = delete;
|
||||
|
||||
// Settings values
|
||||
bool m_fullscreen = false;
|
||||
// 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;
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include "../interfaces/IInputHandler.h"
|
||||
#include <filesystem>
|
||||
#include "../../audio/Audio.h"
|
||||
#include "../../audio/AudioManager.h"
|
||||
#include "../../audio/SoundEffect.h"
|
||||
#include "../../persistence/Scores.h"
|
||||
#include "../../states/State.h"
|
||||
@ -25,20 +26,31 @@
|
||||
#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 <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include "../../utils/ImagePathResolver.h"
|
||||
#include <iostream>
|
||||
#include "../../video/VideoPlayer.h"
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
// (Intro video playback is now handled in-process via VideoPlayer)
|
||||
|
||||
ApplicationManager::ApplicationManager() = default;
|
||||
|
||||
static void traceFile(const char* msg) {
|
||||
std::ofstream f("tetris_trace.log", std::ios::app);
|
||||
std::ofstream f("spacetris_trace.log", std::ios::app);
|
||||
if (f) f << msg << "\n";
|
||||
}
|
||||
|
||||
@ -54,7 +66,15 @@ void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& r
|
||||
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) {
|
||||
@ -248,7 +268,7 @@ void ApplicationManager::shutdown() {
|
||||
m_running = false;
|
||||
|
||||
// Stop audio systems before tearing down SDL to avoid aborts/asserts
|
||||
Audio::instance().shutdown();
|
||||
if (auto sys = AudioManager::get()) sys->shutdown();
|
||||
SoundEffectManager::instance().shutdown();
|
||||
|
||||
// Cleanup in reverse order of initialization
|
||||
@ -328,6 +348,26 @@ bool ApplicationManager::initializeManagers() {
|
||||
|
||||
// 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) &&
|
||||
@ -342,11 +382,11 @@ bool ApplicationManager::initializeManagers() {
|
||||
|
||||
// M: Toggle/mute music; start playback if unmuting and not started yet
|
||||
if (!consume && sc == SDL_SCANCODE_M) {
|
||||
Audio::instance().toggleMute();
|
||||
if (auto sys = AudioManager::get()) sys->toggleMute();
|
||||
m_musicEnabled = !m_musicEnabled;
|
||||
if (m_musicEnabled && !m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
|
||||
Audio::instance().shuffle();
|
||||
Audio::instance().start();
|
||||
if (m_musicEnabled && !m_musicStarted && AudioManager::get() && AudioManager::get()->getLoadedTrackCount() > 0) {
|
||||
AudioManager::get()->shuffle();
|
||||
AudioManager::get()->start();
|
||||
m_musicStarted = true;
|
||||
}
|
||||
consume = true;
|
||||
@ -354,16 +394,13 @@ bool ApplicationManager::initializeManagers() {
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (auto sys = AudioManager::get()) { sys->skipToNextTrack(); if (!m_musicStarted && sys->getLoadedTrackCount() > 0) { m_musicStarted = true; m_musicEnabled = true; } }
|
||||
consume = true;
|
||||
}
|
||||
|
||||
if (!consume && sc == SDL_SCANCODE_H) {
|
||||
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) {
|
||||
@ -475,13 +512,13 @@ void ApplicationManager::registerServices() {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IInputHandler service");
|
||||
}
|
||||
|
||||
// Register Audio system singleton
|
||||
auto& audioInstance = Audio::instance();
|
||||
auto audioPtr = std::shared_ptr<Audio>(&audioInstance, [](Audio*) {
|
||||
// Custom deleter that does nothing since Audio is a singleton
|
||||
});
|
||||
// Register Audio system singleton (via AudioManager)
|
||||
IAudioSystem* audioInstance = AudioManager::get();
|
||||
if (audioInstance) {
|
||||
std::shared_ptr<IAudioSystem> audioPtr(audioInstance, [](IAudioSystem*){});
|
||||
m_serviceContainer.registerSingleton<IAudioSystem>(audioPtr);
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IAudioSystem service");
|
||||
}
|
||||
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Service registration completed successfully");
|
||||
}
|
||||
@ -540,6 +577,7 @@ bool ApplicationManager::initializeGame() {
|
||||
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
||||
}
|
||||
m_game = std::make_unique<Game>(m_startLevelSelection);
|
||||
m_coopGame = std::make_unique<CoopGame>(m_startLevelSelection);
|
||||
// Wire up sound callbacks as main.cpp did
|
||||
if (m_game) {
|
||||
// Apply global gravity speed multiplier from config
|
||||
@ -559,13 +597,25 @@ bool ApplicationManager::initializeGame() {
|
||||
});
|
||||
}
|
||||
|
||||
if (m_coopGame) {
|
||||
// TODO: tune gravity with Config and shared level scaling once coop rules are finalized
|
||||
m_coopGame->reset(m_startLevelSelection);
|
||||
// Wire coop sound callback to reuse same clear-line VO/SFX behavior
|
||||
m_coopGame->setSoundCallback([&](int linesCleared){
|
||||
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||
if (linesCleared == 2) SoundEffectManager::instance().playRandomSound({"nice_combo"}, 1.0f);
|
||||
else if (linesCleared == 3) SoundEffectManager::instance().playRandomSound({"great_move"}, 1.0f);
|
||||
else if (linesCleared == 4) SoundEffectManager::instance().playRandomSound({"amazing"}, 1.0f);
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare a StateContext-like struct by setting up handlers that capture
|
||||
// pointers and flags. State objects in this refactor expect these to be
|
||||
// available via StateManager event/update/render hooks, so we'll store them
|
||||
// as lambdas that reference members here.
|
||||
|
||||
// Start background music loading similar to main.cpp: Audio init + file discovery
|
||||
Audio::instance().init();
|
||||
if (auto sys = AudioManager::get()) sys->init();
|
||||
// Discover available tracks (up to 100) and queue for background loading
|
||||
m_totalTracks = 0;
|
||||
std::vector<std::string> trackPaths;
|
||||
@ -581,15 +631,15 @@ bool ApplicationManager::initializeGame() {
|
||||
}
|
||||
m_totalTracks = static_cast<int>(trackPaths.size());
|
||||
for (const auto& path : trackPaths) {
|
||||
Audio::instance().addTrackAsync(path);
|
||||
if (auto sys = AudioManager::get()) sys->addTrackAsync(path);
|
||||
}
|
||||
if (m_totalTracks > 0) {
|
||||
Audio::instance().startBackgroundLoading();
|
||||
if (auto sys = AudioManager::get()) sys->startBackgroundLoading();
|
||||
// Kick off playback now; Audio will pick a track once decoded.
|
||||
// Do not mark as started yet; we'll flip the flag once a track is actually loaded.
|
||||
if (m_musicEnabled) {
|
||||
Audio::instance().shuffle();
|
||||
Audio::instance().start();
|
||||
if (auto sys = AudioManager::get()) { sys->shuffle(); sys->start(); }
|
||||
m_musicStarted = true;
|
||||
}
|
||||
m_currentTrackLoading = 1; // mark started
|
||||
}
|
||||
@ -600,6 +650,7 @@ bool ApplicationManager::initializeGame() {
|
||||
{
|
||||
m_stateContext.stateManager = m_stateManager.get();
|
||||
m_stateContext.game = m_game.get();
|
||||
m_stateContext.coopGame = m_coopGame.get();
|
||||
m_stateContext.scores = m_scoreManager.get();
|
||||
m_stateContext.starfield = m_starfield.get();
|
||||
m_stateContext.starfield3D = m_starfield3D.get();
|
||||
@ -622,6 +673,7 @@ bool ApplicationManager::initializeGame() {
|
||||
} else { m_stateContext.logoSmallW = 0; m_stateContext.logoSmallH = 0; }
|
||||
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
||||
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
||||
m_stateContext.asteroidsTex = m_assetManager->getTexture("asteroids");
|
||||
m_stateContext.musicEnabled = &m_musicEnabled;
|
||||
m_stateContext.musicStarted = &m_musicStarted;
|
||||
m_stateContext.musicLoaded = &m_musicLoaded;
|
||||
@ -743,17 +795,44 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_starfield3D->update(deltaTime / 1000.0f);
|
||||
}
|
||||
|
||||
// Check if loading is complete and transition to menu
|
||||
// Check if loading is complete and transition to next stage
|
||||
if (m_assetManager->isLoadingComplete()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu");
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow");
|
||||
|
||||
// Update texture pointers now that assets are loaded
|
||||
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
||||
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
||||
|
||||
bool ok = m_stateManager->setState(AppState::Menu);
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0);
|
||||
traceFile("- to Menu returned");
|
||||
// If an intro video exists and hasn't been started, attempt to play it in-process
|
||||
std::filesystem::path introPath = m_introPath;
|
||||
if (!m_introStarted && std::filesystem::exists(introPath)) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str());
|
||||
try {
|
||||
if (!m_videoPlayer) m_videoPlayer = std::make_unique<VideoPlayer>();
|
||||
SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr;
|
||||
if (m_videoPlayer->open(introPath.string(), sdlRend)) {
|
||||
m_introStarted = true;
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process");
|
||||
} else {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping");
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what());
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
} else if (m_introStarted) {
|
||||
// Let VideoPlayer decode frames; once finished, transition to playing
|
||||
if (m_videoPlayer) m_videoPlayer->update();
|
||||
if (!m_videoPlayer || m_videoPlayer->isFinished()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing");
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
} else {
|
||||
// No intro to play; transition directly to Playing
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing");
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -859,15 +938,15 @@ void ApplicationManager::setupStateHandlers() {
|
||||
// Start music as soon as at least one track has decoded (don’t wait for all)
|
||||
// Start music as soon as at least one track has decoded (don't wait for all)
|
||||
if (m_musicEnabled && !m_musicStarted) {
|
||||
if (Audio::instance().getLoadedTrackCount() > 0) {
|
||||
Audio::instance().shuffle();
|
||||
Audio::instance().start();
|
||||
m_musicStarted = true;
|
||||
if (auto sys = AudioManager::get()) {
|
||||
if (sys->getLoadedTrackCount() > 0) { sys->shuffle(); sys->start(); m_musicStarted = true; }
|
||||
}
|
||||
}
|
||||
// Track completion status for UI
|
||||
if (!m_musicLoaded && Audio::instance().isLoadingComplete()) {
|
||||
m_musicLoaded = true;
|
||||
if (!m_musicLoaded) {
|
||||
if (auto sys = AudioManager::get()) {
|
||||
if (sys->isLoadingComplete()) m_musicLoaded = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -895,8 +974,8 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_showExitConfirmPopup = true;
|
||||
return;
|
||||
}
|
||||
// S: toggle SFX enable state (music handled globally)
|
||||
if (event.key.scancode == SDL_SCANCODE_S) {
|
||||
// K: toggle SFX enable state (music handled globally)
|
||||
if (event.key.scancode == SDL_SCANCODE_K) {
|
||||
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
||||
}
|
||||
}
|
||||
@ -1141,9 +1220,12 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_stateContext.pixelFont,
|
||||
m_stateContext.lineEffect,
|
||||
m_stateContext.blocksTex,
|
||||
m_stateContext.asteroidsTex,
|
||||
m_stateContext.statisticsPanelTex,
|
||||
m_stateContext.scorePanelTex,
|
||||
m_stateContext.nextPanelTex,
|
||||
m_stateContext.holdPanelTex,
|
||||
false,
|
||||
LOGICAL_W,
|
||||
LOGICAL_H,
|
||||
logicalScale,
|
||||
@ -1192,13 +1274,25 @@ void ApplicationManager::setupStateHandlers() {
|
||||
// "GAME OVER" title
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, {255, 80, 60, 255});
|
||||
|
||||
// Game stats
|
||||
// Game stats (single-player or coop combined)
|
||||
char buf[128];
|
||||
if (m_stateContext.game && m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame) {
|
||||
int leftScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Left);
|
||||
int rightScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Right);
|
||||
int total = leftScore + rightScore;
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d + %d = %d LINES %d LEVEL %d",
|
||||
leftScore,
|
||||
rightScore,
|
||||
total,
|
||||
m_stateContext.coopGame->lines(),
|
||||
m_stateContext.coopGame->level());
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level());
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 180, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||
m_stateContext.game ? m_stateContext.game->score() : 0,
|
||||
m_stateContext.game ? m_stateContext.game->lines() : 0,
|
||||
m_stateContext.game ? m_stateContext.game->level() : 0);
|
||||
}
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 220, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||
|
||||
// Instructions
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255});
|
||||
@ -1213,8 +1307,90 @@ void ApplicationManager::setupStateHandlers() {
|
||||
[this](double frameMs) {
|
||||
if (!m_stateContext.game) return;
|
||||
|
||||
const bool coopActive = m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame;
|
||||
|
||||
// Get current keyboard state
|
||||
const bool *ks = SDL_GetKeyboardState(nullptr);
|
||||
|
||||
if (coopActive) {
|
||||
// Paused: suppress all continuous input so pieces don't drift while paused.
|
||||
if (m_stateContext.game->isPaused()) {
|
||||
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||
m_p1MoveTimerMs = 0.0;
|
||||
m_p2MoveTimerMs = 0.0;
|
||||
m_p1LeftHeld = false;
|
||||
m_p1RightHeld = false;
|
||||
m_p2LeftHeld = false;
|
||||
m_p2RightHeld = false;
|
||||
return;
|
||||
}
|
||||
|
||||
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||
bool leftHeld,
|
||||
bool rightHeld,
|
||||
double& timer,
|
||||
SDL_Scancode leftKey,
|
||||
SDL_Scancode rightKey,
|
||||
SDL_Scancode downKey) {
|
||||
bool left = ks[leftKey];
|
||||
bool right = ks[rightKey];
|
||||
bool down = ks[downKey];
|
||||
|
||||
// Soft drop flag
|
||||
m_stateContext.coopGame->setSoftDropping(side, down);
|
||||
|
||||
int moveDir = 0;
|
||||
if (left && !right) moveDir = -1;
|
||||
else if (right && !left) moveDir = +1;
|
||||
|
||||
if (moveDir != 0) {
|
||||
if ((moveDir == -1 && !leftHeld) || (moveDir == +1 && !rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.coopGame->move(side, moveDir);
|
||||
timer = DAS;
|
||||
} else {
|
||||
timer -= frameMs;
|
||||
if (timer <= 0) {
|
||||
m_stateContext.coopGame->move(side, moveDir);
|
||||
timer += ARR;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timer = 0.0;
|
||||
}
|
||||
|
||||
// Soft drop boost: coop uses same gravity path; fall acceleration handled inside tickGravity
|
||||
};
|
||||
|
||||
// Left player (WASD): A/D horizontal, S soft drop
|
||||
handleSide(CoopGame::PlayerSide::Left, m_p1LeftHeld, m_p1RightHeld, m_p1MoveTimerMs,
|
||||
SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
||||
// Right player (arrows): Left/Right horizontal, Down soft drop
|
||||
handleSide(CoopGame::PlayerSide::Right, m_p2LeftHeld, m_p2RightHeld, m_p2MoveTimerMs,
|
||||
SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
||||
|
||||
// Update held flags for next frame
|
||||
m_p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||
m_p1RightHeld = ks[SDL_SCANCODE_D];
|
||||
m_p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||
m_p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||
|
||||
// Gravity / effects
|
||||
m_stateContext.coopGame->tickGravity(frameMs);
|
||||
m_stateContext.coopGame->updateVisualEffects(frameMs);
|
||||
|
||||
// Delegate to PlayingState for any ancillary updates (renderer transport bookkeeping)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Game over transition for coop
|
||||
if (m_stateContext.coopGame->isGameOver()) {
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
|
||||
} else {
|
||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||
@ -1272,15 +1448,19 @@ void ApplicationManager::setupStateHandlers() {
|
||||
if (m_stateContext.game->isGameOver()) {
|
||||
// Submit score before transitioning
|
||||
if (m_stateContext.scores) {
|
||||
std::string gt = (m_stateContext.game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||
m_stateContext.scores->submit(
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level(),
|
||||
m_stateContext.game->elapsed()
|
||||
m_stateContext.game->elapsed(),
|
||||
std::string("PLAYER"),
|
||||
gt
|
||||
);
|
||||
}
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Debug overlay: show current window and logical sizes on the right side of the screen
|
||||
auto debugOverlay = [this](RenderManager& renderer) {
|
||||
|
||||
@ -17,6 +17,7 @@ class Starfield;
|
||||
class Starfield3D;
|
||||
class FontAtlas;
|
||||
class LineEffect;
|
||||
class CoopGame;
|
||||
|
||||
// Forward declare state classes (top-level, defined under src/states)
|
||||
class LoadingState;
|
||||
@ -109,6 +110,7 @@ private:
|
||||
std::unique_ptr<ScoreManager> m_scoreManager;
|
||||
// Gameplay pieces
|
||||
std::unique_ptr<Game> m_game;
|
||||
std::unique_ptr<CoopGame> m_coopGame;
|
||||
std::unique_ptr<LineEffect> m_lineEffect;
|
||||
|
||||
// DAS/ARR movement timing (from original main.cpp)
|
||||
@ -118,6 +120,14 @@ private:
|
||||
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
||||
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
||||
|
||||
// Coop DAS/ARR per player
|
||||
bool m_p1LeftHeld = false;
|
||||
bool m_p1RightHeld = false;
|
||||
bool m_p2LeftHeld = false;
|
||||
bool m_p2RightHeld = false;
|
||||
double m_p1MoveTimerMs = 0.0;
|
||||
double m_p2MoveTimerMs = 0.0;
|
||||
|
||||
// State context (must be a member to ensure lifetime)
|
||||
StateContext m_stateContext;
|
||||
|
||||
@ -143,6 +153,11 @@ private:
|
||||
float m_logoAnimCounter = 0.0f;
|
||||
bool m_helpOverlayPausedGame = false;
|
||||
|
||||
// Intro video playback (in-process via FFmpeg)
|
||||
bool m_introStarted = false;
|
||||
std::string m_introPath = "assets/videos/spacetris_intro.mp4";
|
||||
std::unique_ptr<class VideoPlayer> m_videoPlayer;
|
||||
|
||||
// Gameplay background (per-level) with fade, mirroring main.cpp behavior
|
||||
SDL_Texture* m_levelBackgroundTex = nullptr;
|
||||
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
#include "AssetManager.h"
|
||||
#include "../../graphics/ui/Font.h"
|
||||
#include "../../audio/Audio.h"
|
||||
#include "../../audio/AudioManager.h"
|
||||
#include "../../audio/SoundEffect.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <filesystem>
|
||||
#include "../../utils/ImagePathResolver.h"
|
||||
#include "../../core/Config.h"
|
||||
#include "../../resources/AssetPaths.h"
|
||||
|
||||
AssetManager::AssetManager()
|
||||
: m_renderer(nullptr)
|
||||
@ -38,7 +41,7 @@ bool AssetManager::initialize(SDL_Renderer* renderer) {
|
||||
m_renderer = renderer;
|
||||
|
||||
// Get references to singleton systems
|
||||
m_audioSystem = &Audio::instance();
|
||||
m_audioSystem = AudioManager::get();
|
||||
m_soundSystem = &SoundEffectManager::instance();
|
||||
|
||||
m_initialized = true;
|
||||
@ -103,7 +106,34 @@ SDL_Texture* AssetManager::loadTexture(const std::string& id, const std::string&
|
||||
|
||||
SDL_Texture* AssetManager::getTexture(const std::string& id) const {
|
||||
auto it = m_textures.find(id);
|
||||
return (it != m_textures.end()) ? it->second : nullptr;
|
||||
if (it != m_textures.end()) return it->second;
|
||||
|
||||
// Lazy fallback: attempt to load well-known short ids from configured asset paths.
|
||||
std::vector<std::string> candidates;
|
||||
if (id == "logo") {
|
||||
candidates.push_back(std::string(Assets::LOGO));
|
||||
candidates.push_back(Config::Assets::LOGO_BMP);
|
||||
} else if (id == "logo_small") {
|
||||
candidates.push_back(Config::Assets::LOGO_SMALL_BMP);
|
||||
candidates.push_back(std::string(Assets::LOGO));
|
||||
} else if (id == "background") {
|
||||
candidates.push_back(std::string(Assets::MAIN_SCREEN));
|
||||
candidates.push_back(Config::Assets::BACKGROUND_BMP);
|
||||
} else if (id == "blocks") {
|
||||
candidates.push_back(std::string(Assets::BLOCKS_SPRITE));
|
||||
candidates.push_back(Config::Assets::BLOCKS_BMP);
|
||||
} else if (id == "asteroids") {
|
||||
candidates.push_back(std::string(Assets::ASTEROID_SPRITE));
|
||||
}
|
||||
|
||||
for (const auto &candidatePath : candidates) {
|
||||
if (candidatePath.empty()) continue;
|
||||
AssetManager* self = const_cast<AssetManager*>(this);
|
||||
SDL_Texture* tex = self->loadTexture(id, candidatePath);
|
||||
if (tex) return tex;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool AssetManager::unloadTexture(const std::string& id) {
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
#include <memory>
|
||||
#include <functional>
|
||||
#include "../interfaces/IAssetLoader.h"
|
||||
#include "../interfaces/IAssetLoader.h"
|
||||
|
||||
// Forward declarations
|
||||
class FontAtlas;
|
||||
class Audio;
|
||||
class SoundEffectManager;
|
||||
class IAudioSystem;
|
||||
|
||||
/**
|
||||
* AssetManager - Centralized resource management following SOLID principles
|
||||
@ -121,7 +121,7 @@ private:
|
||||
|
||||
// System references
|
||||
SDL_Renderer* m_renderer;
|
||||
Audio* m_audioSystem; // Pointer to singleton
|
||||
IAudioSystem* m_audioSystem; // Pointer to audio system (IAudioSystem)
|
||||
SoundEffectManager* m_soundSystem; // Pointer to singleton
|
||||
|
||||
// Configuration
|
||||
|
||||
@ -18,7 +18,7 @@ void InputManager::processEvents() {
|
||||
while (SDL_PollEvent(&event)) {
|
||||
// Trace every polled event type for debugging abrupt termination
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "InputManager: polled event type=%d\n", (int)event.type); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "InputManager: polled event type=%d\n", (int)event.type); fclose(f); }
|
||||
}
|
||||
switch (event.type) {
|
||||
case SDL_EVENT_QUIT:
|
||||
@ -349,7 +349,7 @@ void InputManager::reset() {
|
||||
}
|
||||
|
||||
void InputManager::handleQuitEvent() {
|
||||
FILE* f = fopen("tetris_trace.log", "a");
|
||||
FILE* f = fopen("spacetris_trace.log", "a");
|
||||
if (f) {
|
||||
fprintf(f, "InputManager::handleQuitEvent invoked\n");
|
||||
fclose(f);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @brief Abstract interface for audio system operations
|
||||
@ -52,4 +54,28 @@ public:
|
||||
* @return true if music is playing, false otherwise
|
||||
*/
|
||||
virtual bool isMusicPlaying() const = 0;
|
||||
|
||||
// Extended control methods used by the application
|
||||
virtual bool init() = 0;
|
||||
virtual void shutdown() = 0;
|
||||
|
||||
virtual void addTrack(const std::string& path) = 0;
|
||||
virtual void addTrackAsync(const std::string& path) = 0;
|
||||
virtual void startBackgroundLoading() = 0;
|
||||
virtual bool isLoadingComplete() const = 0;
|
||||
virtual int getLoadedTrackCount() const = 0;
|
||||
|
||||
virtual void start() = 0;
|
||||
virtual void skipToNextTrack() = 0;
|
||||
virtual void shuffle() = 0;
|
||||
|
||||
virtual void toggleMute() = 0;
|
||||
virtual bool isMuted() const = 0;
|
||||
|
||||
virtual void setMenuTrack(const std::string& path) = 0;
|
||||
virtual void playMenuMusic() = 0;
|
||||
virtual void playGameMusic() = 0;
|
||||
|
||||
// Low-level SFX path (raw PCM) used by internal SFX mixer
|
||||
virtual void playSfx(const std::vector<int16_t>& pcm, int channels, int rate, float volume) = 0;
|
||||
};
|
||||
|
||||
@ -86,7 +86,7 @@ bool StateManager::setState(AppState newState) {
|
||||
getStateName(m_currentState), getStateName(newState));
|
||||
// Persistent trace for debugging abrupt exits
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "setState start %s -> %s\n", getStateName(m_currentState), getStateName(newState)); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "setState start %s -> %s\n", getStateName(m_currentState), getStateName(newState)); fclose(f); }
|
||||
}
|
||||
|
||||
// Execute exit hooks for current state
|
||||
@ -101,7 +101,7 @@ bool StateManager::setState(AppState newState) {
|
||||
|
||||
// Trace completion
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "setState end %s\n", getStateName(m_currentState)); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "setState end %s\n", getStateName(m_currentState)); fclose(f); }
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) {
|
||||
}
|
||||
|
||||
bool StateManager::isValidState(AppState state) const {
|
||||
// All enum values are currently valid
|
||||
return static_cast<int>(state) >= static_cast<int>(AppState::Loading) &&
|
||||
static_cast<int>(state) <= static_cast<int>(AppState::GameOver);
|
||||
switch (state) {
|
||||
case AppState::Loading:
|
||||
case AppState::Video:
|
||||
case AppState::Menu:
|
||||
case AppState::Options:
|
||||
case AppState::LevelSelector:
|
||||
case AppState::Playing:
|
||||
case AppState::LevelSelect:
|
||||
case AppState::GameOver:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool StateManager::canTransitionTo(AppState newState) const {
|
||||
@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const {
|
||||
const char* StateManager::getStateName(AppState state) const {
|
||||
switch (state) {
|
||||
case AppState::Loading: return "Loading";
|
||||
case AppState::Video: return "Video";
|
||||
case AppState::Menu: return "Menu";
|
||||
case AppState::Options: return "Options";
|
||||
case AppState::LevelSelector: return "LevelSelector";
|
||||
@ -190,7 +201,7 @@ void StateManager::executeEnterHooks(AppState state) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Executing enter hook %d for state %s", idx, getStateName(state));
|
||||
// Also write to trace file for persistent record
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "executeEnterHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "executeEnterHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
||||
}
|
||||
try {
|
||||
hook();
|
||||
@ -212,7 +223,7 @@ void StateManager::executeExitHooks(AppState state) {
|
||||
for (auto& hook : it->second) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Executing exit hook %d for state %s", idx, getStateName(state));
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "executeExitHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "executeExitHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
||||
}
|
||||
try {
|
||||
hook();
|
||||
|
||||
@ -12,6 +12,7 @@ class RenderManager;
|
||||
// Application states used across the app
|
||||
enum class AppState {
|
||||
Loading,
|
||||
Video,
|
||||
Menu,
|
||||
Options,
|
||||
LevelSelector,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/AudioManager.h"
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
@ -266,6 +267,6 @@ void LineEffect::playLineClearSound(int lineCount) {
|
||||
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
|
||||
if (sample && !sample->empty()) {
|
||||
// Mix via shared Audio device so it layers with music
|
||||
Audio::instance().playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
if (auto sys = AudioManager::get()) sys->playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
}
|
||||
}
|
||||
|
||||
317
src/gameplay/coop/CoopAIController.cpp
Normal file
@ -0,0 +1,317 @@
|
||||
#include "CoopAIController.h"
|
||||
|
||||
#include "CoopGame.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
|
||||
static bool canPlacePieceForSide(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
|
||||
const CoopGame::Piece& p,
|
||||
CoopGame::PlayerSide side) {
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(p, cx, cy)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int bx = p.x + cx;
|
||||
const int by = p.y + cy;
|
||||
|
||||
// Keep the AI strictly in the correct half.
|
||||
if (side == CoopGame::PlayerSide::Right) {
|
||||
if (bx < 10 || bx >= CoopGame::COLS) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (bx < 0 || bx >= 10) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Above the visible board is allowed.
|
||||
if (by < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (by >= CoopGame::ROWS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (board[by * CoopGame::COLS + bx].occupied) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int dropYFor(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
|
||||
CoopGame::Piece p,
|
||||
CoopGame::PlayerSide side) {
|
||||
// Assumes p is currently placeable.
|
||||
while (true) {
|
||||
CoopGame::Piece next = p;
|
||||
next.y += 1;
|
||||
if (!canPlacePieceForSide(board, next, side)) {
|
||||
return p.y;
|
||||
}
|
||||
p = next;
|
||||
if (p.y > CoopGame::ROWS) {
|
||||
return p.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void applyPiece(std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS>& occ,
|
||||
const CoopGame::Piece& p) {
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!CoopGame::cellFilled(p, cx, cy)) {
|
||||
continue;
|
||||
}
|
||||
const int bx = p.x + cx;
|
||||
const int by = p.y + cy;
|
||||
if (by < 0 || by >= CoopGame::ROWS || bx < 0 || bx >= CoopGame::COLS) {
|
||||
continue;
|
||||
}
|
||||
occ[by * CoopGame::COLS + bx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Eval {
|
||||
double score = -std::numeric_limits<double>::infinity();
|
||||
int rot = 0;
|
||||
int x = 10;
|
||||
};
|
||||
|
||||
static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) {
|
||||
const auto& board = game.boardRef();
|
||||
|
||||
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> occ{};
|
||||
for (int i = 0; i < CoopGame::COLS * CoopGame::ROWS; ++i) {
|
||||
occ[i] = board[i].occupied ? 1 : 0;
|
||||
}
|
||||
|
||||
const CoopGame::Piece cur = game.current(side);
|
||||
|
||||
Eval best{};
|
||||
|
||||
// Iterate rotations and x positions. IMPORTANT: allow x to go slightly out of bounds
|
||||
// because our pieces are represented in a 4x4 mask and many rotations have leading
|
||||
// empty columns. For example, placing a vertical I/J/L into column 0 often requires
|
||||
// p.x == -1 or p.x == -2 so the filled cells land at bx==0.
|
||||
// canPlacePieceForSide() enforces the actual half-board bounds.
|
||||
for (int rot = 0; rot < 4; ++rot) {
|
||||
int xmin = (side == CoopGame::PlayerSide::Right) ? 6 : -3;
|
||||
int xmax = (side == CoopGame::PlayerSide::Right) ? 22 : 13;
|
||||
for (int x = xmin; x <= xmax; ++x) {
|
||||
CoopGame::Piece p = cur;
|
||||
p.rot = rot;
|
||||
p.x = x;
|
||||
|
||||
// If this rotation/x is illegal at the current y, try near the top spawn band.
|
||||
if (!canPlacePieceForSide(board, p, side)) {
|
||||
p.y = -2;
|
||||
if (!canPlacePieceForSide(board, p, side)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
p.y = dropYFor(board, p, side);
|
||||
|
||||
auto occ2 = occ;
|
||||
applyPiece(occ2, p);
|
||||
|
||||
// Count completed full rows (all 20 cols) after placement.
|
||||
int fullRows = 0;
|
||||
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||
bool full = true;
|
||||
for (int cx = 0; cx < CoopGame::COLS; ++cx) {
|
||||
if (!occ2[y * CoopGame::COLS + cx]) {
|
||||
full = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (full) {
|
||||
++fullRows;
|
||||
}
|
||||
}
|
||||
|
||||
// Right-half column heights + holes + bumpiness.
|
||||
std::array<int, 10> heights{};
|
||||
int aggregateHeight = 0;
|
||||
int holes = 0;
|
||||
|
||||
for (int c = 0; c < 10; ++c) {
|
||||
const int bx = (side == CoopGame::PlayerSide::Right) ? (10 + c) : c;
|
||||
int h = 0;
|
||||
bool found = false;
|
||||
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||
if (occ2[y * CoopGame::COLS + bx]) {
|
||||
h = CoopGame::ROWS - y;
|
||||
found = true;
|
||||
// Count holes below the first filled cell.
|
||||
for (int yy = y + 1; yy < CoopGame::ROWS; ++yy) {
|
||||
if (!occ2[yy * CoopGame::COLS + bx]) {
|
||||
++holes;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
heights[c] = found ? h : 0;
|
||||
aggregateHeight += heights[c];
|
||||
}
|
||||
|
||||
int bump = 0;
|
||||
for (int i = 0; i < 9; ++i) {
|
||||
bump += std::abs(heights[i] - heights[i + 1]);
|
||||
}
|
||||
|
||||
// Reward sync potential: rows where the right half is full (10..19).
|
||||
int sideHalfFullRows = 0;
|
||||
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||
bool full = true;
|
||||
int start = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
|
||||
int end = (side == CoopGame::PlayerSide::Right) ? 20 : 10;
|
||||
for (int bx = start; bx < end; ++bx) {
|
||||
if (!occ2[y * CoopGame::COLS + bx]) {
|
||||
full = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (full) {
|
||||
++sideHalfFullRows;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple heuristic:
|
||||
// - Strongly prefer completed full rows
|
||||
// - Prefer making the right half complete (helps cooperative clears)
|
||||
// - Penalize holes and excessive height/bumpiness
|
||||
double s = 0.0;
|
||||
// Strongly prefer full-line clears across the whole board (rare but best).
|
||||
s += static_cast<double>(fullRows) * 12000.0;
|
||||
// Heavily prefer completing the player's half — make this a primary objective.
|
||||
s += static_cast<double>(sideHalfFullRows) * 6000.0;
|
||||
// Penalize holes and height less aggressively so completing half-rows is prioritized.
|
||||
s -= static_cast<double>(holes) * 180.0;
|
||||
s -= static_cast<double>(aggregateHeight) * 4.0;
|
||||
s -= static_cast<double>(bump) * 10.0;
|
||||
|
||||
// Reduce center bias so edge placements to complete rows are not punished.
|
||||
double centerTarget = (side == CoopGame::PlayerSide::Right) ? 15.0 : 4.5;
|
||||
const double centerBias = -std::abs((x + 1.5) - centerTarget) * 1.0;
|
||||
s += centerBias;
|
||||
|
||||
if (s > best.score) {
|
||||
best.score = s;
|
||||
best.rot = rot;
|
||||
best.x = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void CoopAIController::reset() {
|
||||
m_lastPieceSeq = 0;
|
||||
m_hasPlan = false;
|
||||
m_targetRot = 0;
|
||||
m_targetX = 10;
|
||||
m_moveTimerMs = 0.0;
|
||||
m_moveDir = 0;
|
||||
m_rotateTimerMs = 0.0;
|
||||
}
|
||||
|
||||
void CoopAIController::computePlan(const CoopGame& game, CoopGame::PlayerSide side) {
|
||||
const Eval best = evaluateBestPlacementForSide(game, side);
|
||||
m_targetRot = best.rot;
|
||||
m_targetX = best.x;
|
||||
m_hasPlan = true;
|
||||
m_moveTimerMs = 0.0;
|
||||
m_moveDir = 0;
|
||||
m_rotateTimerMs = 0.0;
|
||||
}
|
||||
|
||||
void CoopAIController::update(CoopGame& game, CoopGame::PlayerSide side, double frameMs) {
|
||||
const uint64_t seq = game.currentPieceSequence(side);
|
||||
if (seq != m_lastPieceSeq) {
|
||||
m_lastPieceSeq = seq;
|
||||
m_hasPlan = false;
|
||||
m_moveTimerMs = 0.0;
|
||||
m_moveDir = 0;
|
||||
m_rotateTimerMs = 0.0;
|
||||
}
|
||||
|
||||
if (!m_hasPlan) {
|
||||
computePlan(game, side);
|
||||
}
|
||||
|
||||
const CoopGame::Piece cur = game.current(side);
|
||||
|
||||
// Clamp negative deltas (defensive; callers should pass >= 0).
|
||||
const double dt = std::max(0.0, frameMs);
|
||||
|
||||
// Update timers.
|
||||
if (m_moveTimerMs > 0.0) {
|
||||
m_moveTimerMs -= dt;
|
||||
if (m_moveTimerMs < 0.0) m_moveTimerMs = 0.0;
|
||||
}
|
||||
if (m_rotateTimerMs > 0.0) {
|
||||
m_rotateTimerMs -= dt;
|
||||
if (m_rotateTimerMs < 0.0) m_rotateTimerMs = 0.0;
|
||||
}
|
||||
|
||||
// Rotate toward target first.
|
||||
const int curRot = ((cur.rot % 4) + 4) % 4;
|
||||
const int tgtRot = ((m_targetRot % 4) + 4) % 4;
|
||||
int diff = (tgtRot - curRot + 4) % 4;
|
||||
if (diff != 0) {
|
||||
// Human-ish rotation rate limiting.
|
||||
if (m_rotateTimerMs <= 0.0) {
|
||||
const int dir = (diff == 3) ? -1 : 1;
|
||||
game.rotate(side, dir);
|
||||
m_rotateTimerMs = m_rotateIntervalMs;
|
||||
}
|
||||
// While rotating, do not also slide horizontally in the same frame.
|
||||
m_moveDir = 0;
|
||||
m_moveTimerMs = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move horizontally toward target.
|
||||
int desiredDir = 0;
|
||||
if (cur.x < m_targetX) desiredDir = +1;
|
||||
else if (cur.x > m_targetX) desiredDir = -1;
|
||||
|
||||
if (desiredDir == 0) {
|
||||
// Aligned: do nothing. Gravity controls fall speed (no AI hard drops).
|
||||
m_moveDir = 0;
|
||||
m_moveTimerMs = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// DAS/ARR-style horizontal movement pacing.
|
||||
if (m_moveDir != desiredDir) {
|
||||
// New direction / initial press: move immediately, then wait DAS.
|
||||
game.move(side, desiredDir);
|
||||
m_moveDir = desiredDir;
|
||||
m_moveTimerMs = m_dasMs;
|
||||
return;
|
||||
}
|
||||
|
||||
// Holding direction: repeat every ARR once DAS has elapsed.
|
||||
if (m_moveTimerMs <= 0.0) {
|
||||
game.move(side, desiredDir);
|
||||
m_moveTimerMs = m_arrMs;
|
||||
}
|
||||
}
|
||||
36
src/gameplay/coop/CoopAIController.h
Normal file
@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include "CoopGame.h"
|
||||
|
||||
// Minimal, lightweight AI driver for a CoopGame player side (left or right).
|
||||
// It chooses a target rotation/x placement using a simple board heuristic,
|
||||
// then steers the active piece toward that target at a human-like input rate.
|
||||
class CoopAIController {
|
||||
public:
|
||||
CoopAIController() = default;
|
||||
|
||||
void reset();
|
||||
|
||||
// frameMs is the frame delta in milliseconds (same unit used across the gameplay loop).
|
||||
void update(CoopGame& game, CoopGame::PlayerSide side, double frameMs);
|
||||
|
||||
private:
|
||||
uint64_t m_lastPieceSeq = 0;
|
||||
bool m_hasPlan = false;
|
||||
|
||||
int m_targetRot = 0;
|
||||
int m_targetX = 10;
|
||||
|
||||
// Input pacing (ms). These intentionally mirror the defaults used for human input.
|
||||
double m_dasMs = 170.0;
|
||||
double m_arrMs = 40.0;
|
||||
double m_rotateIntervalMs = 110.0;
|
||||
|
||||
// Internal timers/state for rate limiting.
|
||||
double m_moveTimerMs = 0.0;
|
||||
int m_moveDir = 0; // -1, 0, +1
|
||||
double m_rotateTimerMs = 0.0;
|
||||
|
||||
void computePlan(const CoopGame& game, CoopGame::PlayerSide side);
|
||||
};
|
||||
600
src/gameplay/coop/CoopGame.cpp
Normal file
@ -0,0 +1,600 @@
|
||||
#include "CoopGame.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
||||
constexpr double NES_FPS = 60.0988;
|
||||
constexpr double FRAME_MS = 1000.0 / NES_FPS;
|
||||
|
||||
struct LevelGravity { int framesPerCell; double levelMultiplier; };
|
||||
|
||||
LevelGravity LEVEL_TABLE[30] = {
|
||||
{48,1.0}, {43,1.0}, {38,1.0}, {33,1.0}, {28,1.0}, {23,1.0}, {18,1.0}, {13,1.0}, {8,1.0}, {6,1.0},
|
||||
{5,1.0}, {5,1.0}, {5,1.0}, {4,1.0}, {4,1.0}, {4,1.0}, {3,1.0}, {3,1.0}, {3,1.0}, {2,1.0},
|
||||
{2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {1,1.0}
|
||||
};
|
||||
|
||||
inline double gravityMsForLevelInternal(int level, double globalMultiplier) {
|
||||
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
|
||||
const LevelGravity& lg = LEVEL_TABLE[idx];
|
||||
double frames = lg.framesPerCell * lg.levelMultiplier;
|
||||
return frames * FRAME_MS * globalMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0).
|
||||
static const std::array<Shape, PIECE_COUNT> SHAPES = {{
|
||||
Shape{ 0x0F00, 0x2222, 0x00F0, 0x4444 }, // I
|
||||
Shape{ 0x0660, 0x0660, 0x0660, 0x0660 }, // O
|
||||
Shape{ 0x0E40, 0x4C40, 0x4E00, 0x4640 }, // T
|
||||
Shape{ 0x06C0, 0x4620, 0x06C0, 0x4620 }, // S
|
||||
Shape{ 0x0C60, 0x2640, 0x0C60, 0x2640 }, // Z
|
||||
Shape{ 0x08E0, 0x6440, 0x0E20, 0x44C0 }, // J
|
||||
Shape{ 0x02E0, 0x4460, 0x0E80, 0xC440 }, // L
|
||||
}};
|
||||
}
|
||||
|
||||
CoopGame::CoopGame(int startLevel_) {
|
||||
reset(startLevel_);
|
||||
}
|
||||
|
||||
namespace {
|
||||
uint64_t fnv1a64(uint64_t h, const void* data, size_t size) {
|
||||
const uint8_t* p = static_cast<const uint8_t*>(data);
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
h ^= static_cast<uint64_t>(p[i]);
|
||||
h *= 1099511628211ull;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
uint64_t hashPod(uint64_t h, const T& v) {
|
||||
return fnv1a64(h, &v, sizeof(T));
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt) {
|
||||
std::fill(board.begin(), board.end(), Cell{});
|
||||
rowStates.fill(RowHalfState{});
|
||||
completedLines.clear();
|
||||
hardDropCells.clear();
|
||||
hardDropFxId = 0;
|
||||
hardDropShakeTimerMs = 0.0;
|
||||
_score = 0;
|
||||
_lines = 0;
|
||||
_level = startLevel_;
|
||||
startLevel = startLevel_;
|
||||
gravityMs = gravityMsForLevel(_level);
|
||||
gameOver = false;
|
||||
pieceSequence = 0;
|
||||
elapsedMs = 0.0;
|
||||
|
||||
left = PlayerState{};
|
||||
right = PlayerState{ PlayerSide::Right };
|
||||
|
||||
auto initPlayer = [&](PlayerState& ps, uint32_t seed) {
|
||||
ps.canHold = true;
|
||||
ps.hold.type = PIECE_COUNT;
|
||||
ps.softDropping = false;
|
||||
ps.toppedOut = false;
|
||||
ps.fallAcc = 0.0;
|
||||
ps.lockAcc = 0.0;
|
||||
ps.pieceSeq = 0;
|
||||
ps.score = 0;
|
||||
ps.lines = 0;
|
||||
ps.level = startLevel_;
|
||||
ps.tetrisesMade = 0;
|
||||
ps.currentCombo = 0;
|
||||
ps.maxCombo = 0;
|
||||
ps.comboCount = 0;
|
||||
ps.bag.clear();
|
||||
ps.next.type = PIECE_COUNT;
|
||||
ps.rng.seed(seed);
|
||||
refillBag(ps);
|
||||
};
|
||||
|
||||
if (seedOpt.has_value()) {
|
||||
const uint32_t seed = seedOpt.value();
|
||||
initPlayer(left, seed);
|
||||
initPlayer(right, seed ^ 0x9E3779B9u);
|
||||
} else {
|
||||
// Preserve existing behavior: random seed when not in deterministic mode.
|
||||
std::random_device rd;
|
||||
initPlayer(left, static_cast<uint32_t>(rd()));
|
||||
initPlayer(right, static_cast<uint32_t>(rd()));
|
||||
}
|
||||
|
||||
spawn(left);
|
||||
spawn(right);
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::reset(int startLevel_) {
|
||||
resetInternal(startLevel_, std::nullopt);
|
||||
}
|
||||
|
||||
void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) {
|
||||
resetInternal(startLevel_, seed);
|
||||
}
|
||||
|
||||
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
||||
PlayerState& ps = player(side);
|
||||
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
||||
double oldStep = stepFor(ps.softDropping);
|
||||
double newStep = stepFor(on);
|
||||
if (oldStep <= 0.0 || newStep <= 0.0) {
|
||||
ps.softDropping = on;
|
||||
return;
|
||||
}
|
||||
|
||||
double progress = ps.fallAcc / oldStep;
|
||||
progress = std::clamp(progress, 0.0, 1.0);
|
||||
ps.fallAcc = progress * newStep;
|
||||
ps.softDropping = on;
|
||||
}
|
||||
|
||||
uint64_t CoopGame::computeStateHash() const {
|
||||
uint64_t h = 1469598103934665603ull;
|
||||
|
||||
// Board
|
||||
for (const auto& c : board) {
|
||||
const uint8_t occ = c.occupied ? 1u : 0u;
|
||||
const uint8_t owner = (c.owner == PlayerSide::Left) ? 0u : 1u;
|
||||
const uint8_t val = static_cast<uint8_t>(std::clamp(c.value, 0, 255));
|
||||
h = hashPod(h, occ);
|
||||
h = hashPod(h, owner);
|
||||
h = hashPod(h, val);
|
||||
}
|
||||
|
||||
auto hashPiece = [&](const Piece& p) {
|
||||
const uint8_t type = static_cast<uint8_t>(p.type);
|
||||
const int32_t rot = p.rot;
|
||||
const int32_t x = p.x;
|
||||
const int32_t y = p.y;
|
||||
h = hashPod(h, type);
|
||||
h = hashPod(h, rot);
|
||||
h = hashPod(h, x);
|
||||
h = hashPod(h, y);
|
||||
};
|
||||
|
||||
auto hashPlayer = [&](const PlayerState& ps) {
|
||||
const uint8_t side = (ps.side == PlayerSide::Left) ? 0u : 1u;
|
||||
h = hashPod(h, side);
|
||||
hashPiece(ps.cur);
|
||||
hashPiece(ps.next);
|
||||
hashPiece(ps.hold);
|
||||
const uint8_t canHoldB = ps.canHold ? 1u : 0u;
|
||||
const uint8_t toppedOutB = ps.toppedOut ? 1u : 0u;
|
||||
h = hashPod(h, canHoldB);
|
||||
h = hashPod(h, toppedOutB);
|
||||
h = hashPod(h, ps.score);
|
||||
h = hashPod(h, ps.lines);
|
||||
h = hashPod(h, ps.level);
|
||||
h = hashPod(h, ps.tetrisesMade);
|
||||
h = hashPod(h, ps.currentCombo);
|
||||
h = hashPod(h, ps.maxCombo);
|
||||
h = hashPod(h, ps.comboCount);
|
||||
h = hashPod(h, ps.pieceSeq);
|
||||
|
||||
const uint32_t bagSize = static_cast<uint32_t>(ps.bag.size());
|
||||
h = hashPod(h, bagSize);
|
||||
for (auto t : ps.bag) {
|
||||
const uint8_t tt = static_cast<uint8_t>(t);
|
||||
h = hashPod(h, tt);
|
||||
}
|
||||
};
|
||||
|
||||
hashPlayer(left);
|
||||
hashPlayer(right);
|
||||
|
||||
// Session-wide counters/stats
|
||||
h = hashPod(h, _score);
|
||||
h = hashPod(h, _lines);
|
||||
h = hashPod(h, _level);
|
||||
h = hashPod(h, _tetrisesMade);
|
||||
h = hashPod(h, _currentCombo);
|
||||
h = hashPod(h, _maxCombo);
|
||||
h = hashPod(h, _comboCount);
|
||||
h = hashPod(h, startLevel);
|
||||
h = hashPod(h, pieceSequence);
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
void CoopGame::move(PlayerSide side, int dx) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
tryMove(ps, dx, 0);
|
||||
}
|
||||
|
||||
void CoopGame::rotate(PlayerSide side, int dir) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
|
||||
auto minOccupiedY = [&](const Piece& p) -> int {
|
||||
int minY = 999;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(p, cx, cy)) continue;
|
||||
minY = std::min(minY, p.y + cy);
|
||||
}
|
||||
}
|
||||
return (minY == 999) ? p.y : minY;
|
||||
};
|
||||
|
||||
auto tryApplyWithTopKick = [&](const Piece& candidate) -> bool {
|
||||
// If rotation would place any occupied cell above the visible grid,
|
||||
// kick it down just enough to keep all blocks visible.
|
||||
int minY = minOccupiedY(candidate);
|
||||
int baseDy = (minY < 0) ? -minY : 0;
|
||||
|
||||
// Try minimal adjustment first; allow a couple extra pixels/rows for safety.
|
||||
for (int dy = baseDy; dy <= baseDy + 2; ++dy) {
|
||||
Piece test = candidate;
|
||||
test.y += dy;
|
||||
if (!collides(ps, test)) {
|
||||
ps.cur = test;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Piece rotated = ps.cur;
|
||||
rotated.rot = (rotated.rot + dir + 4) % 4;
|
||||
|
||||
// Simple wall kick: try in place, then left, then right.
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
rotated.x -= 1;
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
rotated.x += 2;
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
}
|
||||
|
||||
void CoopGame::hardDrop(PlayerSide side) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
|
||||
hardDropCells.clear();
|
||||
bool moved = false;
|
||||
int dropped = 0;
|
||||
while (tryMove(ps, 0, 1)) {
|
||||
moved = true;
|
||||
dropped++;
|
||||
// Record path for potential effects
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||
int px = ps.cur.x + cx;
|
||||
int py = ps.cur.y + cy;
|
||||
if (py >= 0) {
|
||||
hardDropCells.push_back(SDL_Point{ px, py });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (moved) {
|
||||
_score += dropped; // 1 point per cell, matches single-player hard drop
|
||||
ps.score += dropped;
|
||||
hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS;
|
||||
hardDropFxId++;
|
||||
}
|
||||
lock(ps);
|
||||
}
|
||||
|
||||
void CoopGame::holdCurrent(PlayerSide side) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
if (!ps.canHold) return;
|
||||
if (ps.hold.type == PIECE_COUNT) {
|
||||
ps.hold = ps.cur;
|
||||
spawn(ps);
|
||||
} else {
|
||||
std::swap(ps.cur, ps.hold);
|
||||
ps.cur.rot = 0;
|
||||
ps.cur.x = columnMin(ps.side) + 3;
|
||||
// Match single-player spawn height (I starts higher)
|
||||
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||
ps.pieceSeq++;
|
||||
pieceSequence++;
|
||||
}
|
||||
ps.canHold = false;
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
|
||||
void CoopGame::tickGravity(double frameMs) {
|
||||
if (gameOver) return;
|
||||
|
||||
elapsedMs += frameMs;
|
||||
|
||||
auto stepPlayer = [&](PlayerState& ps) {
|
||||
if (ps.toppedOut) return;
|
||||
double step = ps.softDropping ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||
ps.fallAcc += frameMs;
|
||||
while (ps.fallAcc >= step) {
|
||||
ps.fallAcc -= step;
|
||||
if (!tryMove(ps, 0, 1)) {
|
||||
ps.lockAcc += step;
|
||||
if (ps.lockAcc >= LOCK_DELAY_MS) {
|
||||
lock(ps);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Award soft drop points when actively holding down
|
||||
if (ps.softDropping) {
|
||||
_score += 1;
|
||||
ps.score += 1;
|
||||
}
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stepPlayer(left);
|
||||
stepPlayer(right);
|
||||
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::updateVisualEffects(double frameMs) {
|
||||
if (hardDropShakeTimerMs > 0.0) {
|
||||
hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs);
|
||||
}
|
||||
}
|
||||
|
||||
double CoopGame::hardDropShakeStrength() const {
|
||||
if (hardDropShakeTimerMs <= 0.0) return 0.0;
|
||||
return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0);
|
||||
}
|
||||
|
||||
double CoopGame::gravityMsForLevel(int level) const {
|
||||
return gravityMsForLevelInternal(level, gravityGlobalMultiplier);
|
||||
}
|
||||
|
||||
bool CoopGame::cellFilled(const Piece& p, int cx, int cy) {
|
||||
if (p.type >= PIECE_COUNT) return false;
|
||||
const Shape& shape = SHAPES[p.type];
|
||||
uint16_t mask = shape[p.rot % 4];
|
||||
int bitIndex = cy * 4 + cx;
|
||||
// Masks are defined row-major 4x4 with bit 0 = (0,0) (same convention as classic).
|
||||
return (mask >> bitIndex) & 1;
|
||||
}
|
||||
|
||||
void CoopGame::clearCompletedLines() {
|
||||
if (completedLines.empty()) return;
|
||||
clearLinesInternal();
|
||||
completedLines.clear();
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::refillBag(PlayerState& ps) {
|
||||
ps.bag.clear();
|
||||
ps.bag.reserve(PIECE_COUNT);
|
||||
for (int i = 0; i < PIECE_COUNT; ++i) {
|
||||
ps.bag.push_back(static_cast<PieceType>(i));
|
||||
}
|
||||
std::shuffle(ps.bag.begin(), ps.bag.end(), ps.rng);
|
||||
}
|
||||
|
||||
CoopGame::Piece CoopGame::drawFromBag(PlayerState& ps) {
|
||||
if (ps.bag.empty()) {
|
||||
refillBag(ps);
|
||||
}
|
||||
PieceType t = ps.bag.back();
|
||||
ps.bag.pop_back();
|
||||
Piece p{};
|
||||
p.type = t;
|
||||
return p;
|
||||
}
|
||||
|
||||
void CoopGame::spawn(PlayerState& ps) {
|
||||
if (ps.next.type == PIECE_COUNT) {
|
||||
ps.next = drawFromBag(ps);
|
||||
}
|
||||
ps.cur = ps.next;
|
||||
ps.cur.rot = 0;
|
||||
ps.cur.x = columnMin(ps.side) + 3; // center within side
|
||||
// Match single-player spawn height (I starts higher)
|
||||
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||
ps.next = drawFromBag(ps);
|
||||
ps.canHold = true;
|
||||
ps.softDropping = false;
|
||||
ps.lockAcc = 0.0;
|
||||
ps.fallAcc = 0.0;
|
||||
ps.pieceSeq++;
|
||||
pieceSequence++;
|
||||
if (collides(ps, ps.cur)) {
|
||||
ps.toppedOut = true;
|
||||
// Cooperative mode: game ends when any player tops out.
|
||||
gameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
||||
int minX = columnMin(ps.side);
|
||||
int maxX = columnMax(ps.side);
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(p, cx, cy)) continue;
|
||||
int px = p.x + cx;
|
||||
int py = p.y + cy;
|
||||
if (px < minX || px > maxX) return true;
|
||||
if (py >= ROWS) return true;
|
||||
if (py < 0) continue; // allow spawn above board
|
||||
int idx = py * COLS + px;
|
||||
if (board[idx].occupied) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CoopGame::tryMove(PlayerState& ps, int dx, int dy) {
|
||||
Piece test = ps.cur;
|
||||
test.x += dx;
|
||||
test.y += dy;
|
||||
if (collides(ps, test)) return false;
|
||||
ps.cur = test;
|
||||
if (dy > 0) {
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CoopGame::lock(PlayerState& ps) {
|
||||
// Write piece into the board
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||
int px = ps.cur.x + cx;
|
||||
int py = ps.cur.y + cy;
|
||||
if (py < 0 || py >= ROWS) continue;
|
||||
int idx = py * COLS + px;
|
||||
board[idx].occupied = true;
|
||||
board[idx].owner = ps.side;
|
||||
board[idx].value = static_cast<int>(ps.cur.type) + 1;
|
||||
}
|
||||
}
|
||||
// Detect completed lines and apply rewards but DO NOT clear them here.
|
||||
// Clearing is deferred to the visual `LineEffect` system (as in single-player)
|
||||
findCompletedLines();
|
||||
if (!completedLines.empty()) {
|
||||
int cleared = static_cast<int>(completedLines.size());
|
||||
applyLineClearRewards(ps, cleared);
|
||||
// Notify audio layer if present (matches single-player behavior)
|
||||
if (soundCallback) soundCallback(cleared);
|
||||
// Leave `completedLines` populated; `clearCompletedLines()` will be
|
||||
// invoked by the state when the LineEffect finishes.
|
||||
} else {
|
||||
_currentCombo = 0;
|
||||
ps.currentCombo = 0;
|
||||
}
|
||||
spawn(ps);
|
||||
}
|
||||
|
||||
void CoopGame::findCompletedLines() {
|
||||
completedLines.clear();
|
||||
for (int r = 0; r < ROWS; ++r) {
|
||||
bool leftFull = true;
|
||||
bool rightFull = true;
|
||||
for (int c = 0; c < COLS; ++c) {
|
||||
const Cell& cell = board[r * COLS + c];
|
||||
if (!cell.occupied) {
|
||||
if (c < 10) leftFull = false; else rightFull = false;
|
||||
}
|
||||
}
|
||||
rowStates[r].leftFull = leftFull;
|
||||
rowStates[r].rightFull = rightFull;
|
||||
if (leftFull && rightFull) {
|
||||
completedLines.push_back(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) {
|
||||
if (cleared <= 0) return;
|
||||
|
||||
// Base NES scoring scaled by shared level (level 0 => 1x multiplier)
|
||||
int base = 0;
|
||||
switch (cleared) {
|
||||
case 1: base = 40; break;
|
||||
case 2: base = 100; break;
|
||||
case 3: base = 300; break;
|
||||
case 4: base = 1200; break;
|
||||
default: base = 0; break;
|
||||
}
|
||||
_score += base * (_level + 1);
|
||||
creditPlayer.score += base * (creditPlayer.level + 1);
|
||||
|
||||
// Also award a trivial per-line bonus to both players so clears benefit
|
||||
// both participants equally (as requested).
|
||||
if (cleared > 0) {
|
||||
left.score += cleared;
|
||||
right.score += cleared;
|
||||
}
|
||||
|
||||
_lines += cleared;
|
||||
// Credit both players with the cleared lines so cooperative play counts for both
|
||||
left.lines += cleared;
|
||||
right.lines += cleared;
|
||||
|
||||
_currentCombo += 1;
|
||||
if (_currentCombo > _maxCombo) _maxCombo = _currentCombo;
|
||||
if (cleared > 1) {
|
||||
_comboCount += 1;
|
||||
}
|
||||
if (cleared == 4) {
|
||||
_tetrisesMade += 1;
|
||||
}
|
||||
|
||||
creditPlayer.currentCombo += 1;
|
||||
if (creditPlayer.currentCombo > creditPlayer.maxCombo) creditPlayer.maxCombo = creditPlayer.currentCombo;
|
||||
if (cleared > 1) {
|
||||
creditPlayer.comboCount += 1;
|
||||
}
|
||||
if (cleared == 4) {
|
||||
creditPlayer.tetrisesMade += 1;
|
||||
}
|
||||
|
||||
// Level progression mirrors single-player: threshold after (startLevel+1)*10 then every 10 lines
|
||||
int targetLevel = startLevel;
|
||||
int firstThreshold = (startLevel + 1) * 10;
|
||||
if (_lines >= firstThreshold) {
|
||||
targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10;
|
||||
}
|
||||
if (targetLevel > _level) {
|
||||
_level = targetLevel;
|
||||
gravityMs = gravityMsForLevel(_level);
|
||||
if (levelUpCallback) levelUpCallback(_level);
|
||||
}
|
||||
|
||||
// Per-player level progression mirrors the shared rules but is driven by
|
||||
// that player's credited line clears.
|
||||
{
|
||||
int pTargetLevel = startLevel;
|
||||
int pFirstThreshold = (startLevel + 1) * 10;
|
||||
if (creditPlayer.lines >= pFirstThreshold) {
|
||||
pTargetLevel = startLevel + 1 + (creditPlayer.lines - pFirstThreshold) / 10;
|
||||
}
|
||||
creditPlayer.level = std::max(creditPlayer.level, pTargetLevel);
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::clearLinesInternal() {
|
||||
if (completedLines.empty()) return;
|
||||
std::sort(completedLines.begin(), completedLines.end());
|
||||
for (int idx = static_cast<int>(completedLines.size()) - 1; idx >= 0; --idx) {
|
||||
int row = completedLines[idx];
|
||||
for (int y = row; y > 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[y * COLS + x] = board[(y - 1) * COLS + x];
|
||||
}
|
||||
}
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[x] = Cell{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sound callback (optional) - invoked when lines are detected so audio can play
|
||||
// (set via setSoundCallback)
|
||||
// NOTE: defined inline in header as a std::function member; forward usage above
|
||||
|
||||
void CoopGame::updateRowStates() {
|
||||
for (int r = 0; r < ROWS; ++r) {
|
||||
bool leftFull = true;
|
||||
bool rightFull = true;
|
||||
for (int c = 0; c < COLS; ++c) {
|
||||
const Cell& cell = board[r * COLS + c];
|
||||
if (!cell.occupied) {
|
||||
if (c < 10) leftFull = false; else rightFull = false;
|
||||
}
|
||||
}
|
||||
rowStates[r].leftFull = leftFull;
|
||||
rowStates[r].rightFull = rightFull;
|
||||
}
|
||||
}
|
||||
167
src/gameplay/coop/CoopGame.h
Normal file
@ -0,0 +1,167 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include "../core/Game.h" // For PieceType enums and gravity table helpers
|
||||
|
||||
// Cooperative two-player session with a shared 20-column board split into halves.
|
||||
// This is an early scaffold: rules and rendering hooks will be iterated in follow-up passes.
|
||||
class CoopGame {
|
||||
public:
|
||||
enum class PlayerSide { Left, Right };
|
||||
|
||||
static constexpr int COLS = 20;
|
||||
static constexpr int ROWS = Game::ROWS;
|
||||
static constexpr int TILE = Game::TILE;
|
||||
|
||||
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{0}; int y{-2}; };
|
||||
|
||||
struct Cell {
|
||||
int value{0}; // 0 empty else color index (1..7)
|
||||
PlayerSide owner{PlayerSide::Left};
|
||||
bool occupied{false};
|
||||
};
|
||||
|
||||
struct RowHalfState {
|
||||
bool leftFull{false};
|
||||
bool rightFull{false};
|
||||
};
|
||||
|
||||
struct PlayerState {
|
||||
PlayerSide side{PlayerSide::Left};
|
||||
Piece cur{};
|
||||
Piece hold{};
|
||||
Piece next{};
|
||||
uint64_t pieceSeq{0};
|
||||
bool canHold{true};
|
||||
bool softDropping{false};
|
||||
bool toppedOut{false};
|
||||
double fallAcc{0.0};
|
||||
double lockAcc{0.0};
|
||||
int score{0};
|
||||
int lines{0};
|
||||
int level{0};
|
||||
int tetrisesMade{0};
|
||||
int currentCombo{0};
|
||||
int maxCombo{0};
|
||||
int comboCount{0};
|
||||
std::vector<PieceType> bag{}; // 7-bag queue
|
||||
std::mt19937 rng{ std::random_device{}() };
|
||||
};
|
||||
|
||||
explicit CoopGame(int startLevel = 0);
|
||||
using SoundCallback = std::function<void(int)>;
|
||||
using LevelUpCallback = std::function<void(int)>;
|
||||
void setSoundCallback(SoundCallback cb) { soundCallback = cb; }
|
||||
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
|
||||
|
||||
void reset(int startLevel = 0);
|
||||
void resetDeterministic(int startLevel, uint32_t seed);
|
||||
void tickGravity(double frameMs);
|
||||
void updateVisualEffects(double frameMs);
|
||||
|
||||
// Determinism / desync detection
|
||||
uint64_t computeStateHash() const;
|
||||
|
||||
// Per-player inputs -----------------------------------------------------
|
||||
void setSoftDropping(PlayerSide side, bool on);
|
||||
void move(PlayerSide side, int dx);
|
||||
void rotate(PlayerSide side, int dir); // +1 cw, -1 ccw
|
||||
void hardDrop(PlayerSide side);
|
||||
void holdCurrent(PlayerSide side);
|
||||
|
||||
// Accessors -------------------------------------------------------------
|
||||
const std::array<Cell, COLS * ROWS>& boardRef() const { return board; }
|
||||
const Piece& current(PlayerSide s) const { return player(s).cur; }
|
||||
const Piece& next(PlayerSide s) const { return player(s).next; }
|
||||
const Piece& held(PlayerSide s) const { return player(s).hold; }
|
||||
bool canHold(PlayerSide s) const { return player(s).canHold; }
|
||||
bool isGameOver() const { return gameOver; }
|
||||
int score() const { return _score; }
|
||||
int score(PlayerSide s) const { return player(s).score; }
|
||||
int lines() const { return _lines; }
|
||||
int lines(PlayerSide s) const { return player(s).lines; }
|
||||
int level() const { return _level; }
|
||||
int level(PlayerSide s) const { return player(s).level; }
|
||||
int comboCount() const { return _comboCount; }
|
||||
int maxCombo() const { return _maxCombo; }
|
||||
int tetrisesMade() const { return _tetrisesMade; }
|
||||
int elapsed() const { return static_cast<int>(elapsedMs / 1000.0); }
|
||||
int elapsed(PlayerSide) const { return elapsed(); }
|
||||
int startLevelBase() const { return startLevel; }
|
||||
double getGravityMs() const { return gravityMs; }
|
||||
double getFallAccumulator(PlayerSide s) const { return player(s).fallAcc; }
|
||||
bool isSoftDropping(PlayerSide s) const { return player(s).softDropping; }
|
||||
uint64_t currentPieceSequence(PlayerSide s) const { return player(s).pieceSeq; }
|
||||
const std::vector<int>& getCompletedLines() const { return completedLines; }
|
||||
bool hasCompletedLines() const { return !completedLines.empty(); }
|
||||
void clearCompletedLines();
|
||||
const std::array<RowHalfState, ROWS>& rowHalfStates() const { return rowStates; }
|
||||
|
||||
// Simple visual-effect compatibility (stubbed for now)
|
||||
bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; }
|
||||
double hardDropShakeStrength() const;
|
||||
const std::vector<SDL_Point>& getHardDropCells() const { return hardDropCells; }
|
||||
uint32_t getHardDropFxId() const { return hardDropFxId; }
|
||||
|
||||
static bool cellFilled(const Piece& p, int cx, int cy);
|
||||
|
||||
private:
|
||||
static constexpr double LOCK_DELAY_MS = 500.0;
|
||||
|
||||
void resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt);
|
||||
|
||||
std::array<Cell, COLS * ROWS> board{};
|
||||
std::array<RowHalfState, ROWS> rowStates{};
|
||||
PlayerState left{};
|
||||
PlayerState right{ PlayerSide::Right };
|
||||
|
||||
int _score{0};
|
||||
int _lines{0};
|
||||
int _level{1};
|
||||
int _tetrisesMade{0};
|
||||
int _currentCombo{0};
|
||||
int _maxCombo{0};
|
||||
int _comboCount{0};
|
||||
int startLevel{0};
|
||||
double gravityMs{800.0};
|
||||
double gravityGlobalMultiplier{1.0};
|
||||
bool gameOver{false};
|
||||
|
||||
double elapsedMs{0.0};
|
||||
|
||||
std::vector<int> completedLines;
|
||||
|
||||
// Impact FX
|
||||
double hardDropShakeTimerMs{0.0};
|
||||
static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0;
|
||||
std::vector<SDL_Point> hardDropCells;
|
||||
uint32_t hardDropFxId{0};
|
||||
uint64_t pieceSequence{0};
|
||||
SoundCallback soundCallback;
|
||||
LevelUpCallback levelUpCallback;
|
||||
|
||||
// Helpers ---------------------------------------------------------------
|
||||
PlayerState& player(PlayerSide s) { return s == PlayerSide::Left ? left : right; }
|
||||
const PlayerState& player(PlayerSide s) const { return s == PlayerSide::Left ? left : right; }
|
||||
|
||||
void refillBag(PlayerState& ps);
|
||||
Piece drawFromBag(PlayerState& ps);
|
||||
void spawn(PlayerState& ps);
|
||||
bool collides(const PlayerState& ps, const Piece& p) const;
|
||||
bool tryMove(PlayerState& ps, int dx, int dy);
|
||||
void lock(PlayerState& ps);
|
||||
void findCompletedLines();
|
||||
void clearLinesInternal();
|
||||
void updateRowStates();
|
||||
void applyLineClearRewards(PlayerState& creditPlayer, int cleared);
|
||||
double gravityMsForLevel(int level) const;
|
||||
int columnMin(PlayerSide s) const { return s == PlayerSide::Left ? 0 : 10; }
|
||||
int columnMax(PlayerSide s) const { return s == PlayerSide::Left ? 9 : 19; }
|
||||
};
|
||||
@ -51,7 +51,11 @@ namespace {
|
||||
}
|
||||
|
||||
void Game::reset(int startLevel_) {
|
||||
// Standard reset is primarily for endless; Challenge reuses the same pipeline and then
|
||||
// immediately sets up its own level state.
|
||||
std::fill(board.begin(), board.end(), 0);
|
||||
clearAsteroidGrid();
|
||||
recentAsteroidExplosions.clear();
|
||||
std::fill(blockCounts.begin(), blockCounts.end(), 0);
|
||||
bag.clear();
|
||||
_score = 0; _lines = 0; _level = startLevel_; startLevel = startLevel_;
|
||||
@ -59,6 +63,10 @@ void Game::reset(int startLevel_) {
|
||||
_currentCombo = 0;
|
||||
_maxCombo = 0;
|
||||
_comboCount = 0;
|
||||
challengeComplete = false;
|
||||
challengeLevelActive = false;
|
||||
challengeAdvanceQueued = false;
|
||||
challengeQueuedLevel = 0;
|
||||
// Initialize gravity using NES timing table (ms per cell by level)
|
||||
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
|
||||
fallAcc = 0; gameOver=false; paused=false;
|
||||
@ -72,6 +80,229 @@ void Game::reset(int startLevel_) {
|
||||
refillBag();
|
||||
pieceSequence = 0;
|
||||
spawn();
|
||||
|
||||
if (mode == GameMode::Challenge) {
|
||||
int lvl = startLevel_ <= 0 ? 1 : startLevel_;
|
||||
startChallengeRun(lvl);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::clearAsteroidGrid() {
|
||||
for (auto &cell : asteroidGrid) {
|
||||
cell.reset();
|
||||
}
|
||||
asteroidsRemainingCount = 0;
|
||||
asteroidsTotalThisLevel = 0;
|
||||
}
|
||||
|
||||
void Game::startChallengeRun(int startingLevel) {
|
||||
mode = GameMode::Challenge;
|
||||
int lvl = std::clamp(startingLevel, 1, ASTEROID_MAX_LEVEL);
|
||||
// Reset all stats and timers like a fresh run
|
||||
_score = 0; _lines = 0; _level = lvl; startLevel = lvl;
|
||||
_tetrisesMade = 0;
|
||||
_currentCombo = 0;
|
||||
_maxCombo = 0;
|
||||
_comboCount = 0;
|
||||
_startTime = SDL_GetPerformanceCounter();
|
||||
_pausedTime = 0;
|
||||
_lastPauseStart = 0;
|
||||
// Reseed challenge RNG so levels are deterministic per run but distinct per session
|
||||
if (challengeSeedBase == 0) {
|
||||
challengeSeedBase = static_cast<uint32_t>(SDL_GetTicks());
|
||||
}
|
||||
challengeRng.seed(challengeSeedBase + static_cast<uint32_t>(lvl));
|
||||
challengeAdvanceQueued = false;
|
||||
challengeQueuedLevel = 0;
|
||||
setupChallengeLevel(lvl, false);
|
||||
}
|
||||
|
||||
void Game::beginNextChallengeLevel() {
|
||||
if (mode != GameMode::Challenge || challengeComplete) {
|
||||
return;
|
||||
}
|
||||
challengeAdvanceQueued = false;
|
||||
challengeQueuedLevel = 0;
|
||||
int next = challengeLevelIndex + 1;
|
||||
if (next > ASTEROID_MAX_LEVEL) {
|
||||
challengeComplete = true;
|
||||
challengeLevelActive = false;
|
||||
return;
|
||||
}
|
||||
setupChallengeLevel(next, true);
|
||||
}
|
||||
|
||||
void Game::setupChallengeLevel(int level, bool preserveStats) {
|
||||
challengeLevelIndex = std::clamp(level, 1, ASTEROID_MAX_LEVEL);
|
||||
_level = challengeLevelIndex;
|
||||
startLevel = challengeLevelIndex;
|
||||
challengeComplete = false;
|
||||
challengeLevelActive = true;
|
||||
challengeAdvanceQueued = false;
|
||||
challengeQueuedLevel = 0;
|
||||
// Refresh deterministic RNG for this level
|
||||
challengeRng.seed(challengeSeedBase + static_cast<uint32_t>(challengeLevelIndex));
|
||||
|
||||
// Optionally reset cumulative stats (new run) or keep them (between levels)
|
||||
if (!preserveStats) {
|
||||
std::fill(blockCounts.begin(), blockCounts.end(), 0);
|
||||
_score = 0;
|
||||
_lines = 0;
|
||||
_tetrisesMade = 0;
|
||||
_currentCombo = 0;
|
||||
_comboCount = 0;
|
||||
_maxCombo = 0;
|
||||
_startTime = SDL_GetPerformanceCounter();
|
||||
_pausedTime = 0;
|
||||
_lastPauseStart = 0;
|
||||
} else {
|
||||
_currentCombo = 0;
|
||||
}
|
||||
|
||||
// Clear playfield and piece state
|
||||
std::fill(board.begin(), board.end(), 0);
|
||||
clearAsteroidGrid();
|
||||
completedLines.clear();
|
||||
hardDropCells.clear();
|
||||
hardDropFxId = 0;
|
||||
recentAsteroidExplosions.clear();
|
||||
fallAcc = 0.0;
|
||||
gameOver = false;
|
||||
paused = false;
|
||||
softDropping = false;
|
||||
hold = Piece{};
|
||||
hold.type = PIECE_COUNT;
|
||||
canHold = true;
|
||||
bag.clear();
|
||||
refillBag();
|
||||
pieceSequence = 0;
|
||||
spawn();
|
||||
|
||||
// Challenge gravity scales upward per level (faster = smaller ms per cell)
|
||||
double baseMs = gravityMsForLevel(0, gravityGlobalMultiplier);
|
||||
double speedFactor = 1.0 + static_cast<double>(challengeLevelIndex) * 0.02;
|
||||
gravityMs = (speedFactor > 0.0) ? (baseMs / speedFactor) : baseMs;
|
||||
|
||||
// Place asteroids for this level
|
||||
placeAsteroidsForLevel(challengeLevelIndex);
|
||||
|
||||
if (levelUpCallback) {
|
||||
levelUpCallback(_level);
|
||||
}
|
||||
}
|
||||
|
||||
AsteroidType Game::chooseAsteroidTypeForLevel(int level) {
|
||||
// Simple weight distribution by level bands
|
||||
int normalWeight = 100;
|
||||
int armoredWeight = 0;
|
||||
int fallingWeight = 0;
|
||||
int coreWeight = 0;
|
||||
|
||||
if (level >= 10) {
|
||||
armoredWeight = 20;
|
||||
normalWeight = 80;
|
||||
}
|
||||
if (level >= 20) {
|
||||
fallingWeight = 20;
|
||||
normalWeight = 60;
|
||||
}
|
||||
if (level >= 40) {
|
||||
fallingWeight = 30;
|
||||
armoredWeight = 25;
|
||||
normalWeight = 45;
|
||||
}
|
||||
if (level >= 60) {
|
||||
coreWeight = 20;
|
||||
fallingWeight = 30;
|
||||
armoredWeight = 25;
|
||||
normalWeight = 25;
|
||||
}
|
||||
|
||||
int total = normalWeight + armoredWeight + fallingWeight + coreWeight;
|
||||
if (total <= 0) return AsteroidType::Normal;
|
||||
std::uniform_int_distribution<int> dist(0, total - 1);
|
||||
int pick = dist(challengeRng);
|
||||
if (pick < normalWeight) return AsteroidType::Normal;
|
||||
pick -= normalWeight;
|
||||
if (pick < armoredWeight) return AsteroidType::Armored;
|
||||
pick -= armoredWeight;
|
||||
if (pick < fallingWeight) return AsteroidType::Falling;
|
||||
return AsteroidType::Core;
|
||||
}
|
||||
|
||||
AsteroidCell Game::makeAsteroidForType(AsteroidType t) const {
|
||||
AsteroidCell cell{};
|
||||
cell.type = t;
|
||||
switch (t) {
|
||||
case AsteroidType::Normal:
|
||||
cell.hitsRemaining = 1;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
case AsteroidType::Armored:
|
||||
cell.hitsRemaining = 2;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
case AsteroidType::Falling:
|
||||
cell.hitsRemaining = 2;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
case AsteroidType::Core:
|
||||
cell.hitsRemaining = 3;
|
||||
cell.gravityEnabled = false;
|
||||
break;
|
||||
}
|
||||
cell.visualState = 0;
|
||||
return cell;
|
||||
}
|
||||
|
||||
void Game::placeAsteroidsForLevel(int level) {
|
||||
int desired = std::clamp(level, 1, ASTEROID_MAX_LEVEL);
|
||||
// Placement window grows upward with level but caps at half board
|
||||
int height = std::clamp(2 + level / 3, 2, ROWS / 2);
|
||||
int minRow = ROWS - 1 - height;
|
||||
int maxRow = ROWS - 1;
|
||||
minRow = std::max(0, minRow);
|
||||
|
||||
std::uniform_int_distribution<int> xDist(0, COLS - 1);
|
||||
std::uniform_int_distribution<int> yDist(minRow, maxRow);
|
||||
|
||||
int attempts = 0;
|
||||
const int maxAttempts = desired * 16;
|
||||
while (asteroidsRemainingCount < desired && attempts < maxAttempts) {
|
||||
int x = xDist(challengeRng);
|
||||
int y = yDist(challengeRng);
|
||||
int idx = y * COLS + x;
|
||||
attempts++;
|
||||
if (board[idx] != 0 || asteroidGrid[idx].has_value()) {
|
||||
continue;
|
||||
}
|
||||
AsteroidType type = chooseAsteroidTypeForLevel(level);
|
||||
AsteroidCell cell = makeAsteroidForType(type);
|
||||
board[idx] = asteroidBoardValue(type);
|
||||
asteroidGrid[idx] = cell;
|
||||
++asteroidsRemainingCount;
|
||||
++asteroidsTotalThisLevel;
|
||||
}
|
||||
|
||||
if (asteroidsRemainingCount < desired) {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[CHALLENGE] Placed %d/%d asteroids for level %d", asteroidsRemainingCount, desired, level);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper implementations for asteroid board encoding
|
||||
bool Game::isAsteroidValue(int boardValue) {
|
||||
return boardValue >= ASTEROID_BASE;
|
||||
}
|
||||
|
||||
AsteroidType Game::asteroidTypeFromValue(int boardValue) {
|
||||
int idx = boardValue - ASTEROID_BASE;
|
||||
if (idx < 0) return AsteroidType::Normal;
|
||||
if (idx > static_cast<int>(AsteroidType::Core)) idx = static_cast<int>(AsteroidType::Core);
|
||||
return static_cast<AsteroidType>(idx);
|
||||
}
|
||||
|
||||
int Game::asteroidBoardValue(AsteroidType t) {
|
||||
return ASTEROID_BASE + static_cast<int>(t);
|
||||
}
|
||||
|
||||
double Game::elapsed() const {
|
||||
@ -113,6 +344,16 @@ void Game::setPaused(bool p) {
|
||||
paused = p;
|
||||
}
|
||||
|
||||
int Game::consumeQueuedChallengeLevel() {
|
||||
if (!challengeAdvanceQueued) {
|
||||
return 0;
|
||||
}
|
||||
int next = challengeQueuedLevel;
|
||||
challengeAdvanceQueued = false;
|
||||
challengeQueuedLevel = 0;
|
||||
return next;
|
||||
}
|
||||
|
||||
void Game::setSoftDropping(bool on) {
|
||||
if (softDropping == on) {
|
||||
return;
|
||||
@ -235,6 +476,7 @@ void Game::lockPiece() {
|
||||
_tetrisesMade += 1;
|
||||
}
|
||||
|
||||
if (mode != GameMode::Challenge) {
|
||||
// JS level progression (NES-like) using starting level rules
|
||||
// Both startLevel and _level are 0-based now.
|
||||
int targetLevel = startLevel;
|
||||
@ -253,6 +495,10 @@ void Game::lockPiece() {
|
||||
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
|
||||
if (levelUpCallback) levelUpCallback(_level);
|
||||
}
|
||||
} else {
|
||||
// Challenge keeps level tied to the current challenge stage; gravity already set there
|
||||
_level = challengeLevelIndex;
|
||||
}
|
||||
|
||||
// Trigger sound effect callback for line clears
|
||||
if (soundCallback) {
|
||||
@ -283,6 +529,28 @@ int Game::checkLines() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-play asteroid destroy SFX immediately when a clearing line contains asteroids (reduces latency)
|
||||
if (!completedLines.empty() && mode == GameMode::Challenge) {
|
||||
std::optional<AsteroidType> foundType;
|
||||
for (int y : completedLines) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
int idx = y * COLS + x;
|
||||
if (isAsteroidValue(board[idx])) {
|
||||
foundType = asteroidTypeFromValue(board[idx]);
|
||||
} else if (idx >= 0 && idx < static_cast<int>(asteroidGrid.size()) && asteroidGrid[idx].has_value()) {
|
||||
foundType = asteroidGrid[idx]->type;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (foundType.has_value()) {
|
||||
pendingAsteroidDestroyType = foundType;
|
||||
if (!asteroidDestroySoundPreplayed && asteroidDestroyedCallback) {
|
||||
asteroidDestroySoundPreplayed = true;
|
||||
asteroidDestroyedCallback(*foundType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return static_cast<int>(completedLines.size());
|
||||
}
|
||||
|
||||
@ -295,31 +563,152 @@ void Game::clearCompletedLines() {
|
||||
|
||||
void Game::actualClearLines() {
|
||||
if (completedLines.empty()) return;
|
||||
recentAsteroidExplosions.clear();
|
||||
|
||||
int write = ROWS - 1;
|
||||
std::array<int, COLS*ROWS> newBoard{};
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS> newAst{};
|
||||
for (auto &cell : newAst) cell.reset();
|
||||
std::fill(newBoard.begin(), newBoard.end(), 0);
|
||||
|
||||
handleAsteroidsOnClearedRows(completedLines, newBoard, newAst);
|
||||
|
||||
board = newBoard;
|
||||
asteroidGrid = newAst;
|
||||
|
||||
// Apply asteroid-specific gravity after the board collapses
|
||||
applyAsteroidGravity();
|
||||
|
||||
// Reset preplay latch so future destroys can fire again
|
||||
pendingAsteroidDestroyType.reset();
|
||||
asteroidDestroySoundPreplayed = false;
|
||||
|
||||
if (mode == GameMode::Challenge) {
|
||||
if (asteroidsRemainingCount <= 0) {
|
||||
int nextLevel = challengeLevelIndex + 1;
|
||||
if (nextLevel > ASTEROID_MAX_LEVEL) {
|
||||
challengeComplete = true;
|
||||
challengeLevelActive = false;
|
||||
challengeAdvanceQueued = false;
|
||||
challengeQueuedLevel = 0;
|
||||
} else {
|
||||
challengeAdvanceQueued = true;
|
||||
challengeQueuedLevel = nextLevel;
|
||||
challengeLevelActive = false;
|
||||
setPaused(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Game::handleAsteroidsOnClearedRows(const std::vector<int>& clearedRows,
|
||||
std::array<int, COLS*ROWS>& outBoard,
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS>& outAsteroids) {
|
||||
std::vector<bool> clearedFlags(ROWS, false);
|
||||
for (int r : clearedRows) {
|
||||
if (r >= 0 && r < ROWS) {
|
||||
clearedFlags[r] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Track asteroid count updates during processing
|
||||
int destroyedThisPass = 0;
|
||||
std::optional<AsteroidType> lastDestroyedType;
|
||||
|
||||
// Precompute how many cleared rows are at or below each row to reposition survivors
|
||||
std::array<int, ROWS> clearedBelow{};
|
||||
int running = 0;
|
||||
for (int y = ROWS - 1; y >= 0; --y) {
|
||||
// Check if this row should be cleared
|
||||
bool shouldClear = std::find(completedLines.begin(), completedLines.end(), y) != completedLines.end();
|
||||
|
||||
if (!shouldClear) {
|
||||
// Keep this row, move it down if necessary
|
||||
if (write != y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[write*COLS + x] = board[y*COLS + x];
|
||||
clearedBelow[y] = running;
|
||||
if (clearedFlags[y]) {
|
||||
++running;
|
||||
}
|
||||
}
|
||||
--write;
|
||||
}
|
||||
// If shouldClear is true, we skip this row (effectively removing it)
|
||||
}
|
||||
|
||||
// Clear the top rows that are now empty
|
||||
for (int y = write; y >= 0; --y) {
|
||||
for (int y = ROWS - 1; y >= 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[y*COLS + x] = 0;
|
||||
int srcIdx = y * COLS + x;
|
||||
bool rowCleared = clearedFlags[y];
|
||||
bool isAsteroid = asteroidGrid[srcIdx].has_value();
|
||||
|
||||
if (rowCleared) {
|
||||
if (!isAsteroid) {
|
||||
continue; // normal blocks in cleared rows vanish
|
||||
}
|
||||
|
||||
AsteroidCell cell = *asteroidGrid[srcIdx];
|
||||
if (cell.hitsRemaining > 0) {
|
||||
--cell.hitsRemaining;
|
||||
}
|
||||
if (cell.hitsRemaining == 0) {
|
||||
destroyedThisPass++;
|
||||
lastDestroyedType = cell.type;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update visual/gravity state for surviving asteroids
|
||||
cell.visualState = static_cast<uint8_t>(std::min<int>(3, cell.visualState + 1));
|
||||
if (cell.type == AsteroidType::Falling || cell.type == AsteroidType::Core) {
|
||||
cell.gravityEnabled = true;
|
||||
}
|
||||
|
||||
int destY = y + clearedBelow[y]; // shift down by cleared rows below
|
||||
if (destY >= ROWS) {
|
||||
continue; // off the board after collapse
|
||||
}
|
||||
int destIdx = destY * COLS + x;
|
||||
outBoard[destIdx] = asteroidBoardValue(cell.type);
|
||||
outAsteroids[destIdx] = cell;
|
||||
} else {
|
||||
int destY = y + clearedBelow[y];
|
||||
if (destY >= ROWS) {
|
||||
continue;
|
||||
}
|
||||
int destIdx = destY * COLS + x;
|
||||
outBoard[destIdx] = board[srcIdx];
|
||||
if (isAsteroid) {
|
||||
outAsteroids[destIdx] = asteroidGrid[srcIdx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (destroyedThisPass > 0) {
|
||||
asteroidsRemainingCount = std::max(0, asteroidsRemainingCount - destroyedThisPass);
|
||||
if (!asteroidDestroySoundPreplayed && asteroidDestroyedCallback && lastDestroyedType.has_value()) {
|
||||
asteroidDestroyedCallback(*lastDestroyedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Game::applyAsteroidGravity() {
|
||||
if (asteroidsRemainingCount <= 0) {
|
||||
return;
|
||||
}
|
||||
bool moved = false;
|
||||
do {
|
||||
moved = false;
|
||||
for (int y = ROWS - 2; y >= 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
int idx = y * COLS + x;
|
||||
if (!asteroidGrid[idx].has_value()) {
|
||||
continue;
|
||||
}
|
||||
if (!asteroidGrid[idx]->gravityEnabled) {
|
||||
continue;
|
||||
}
|
||||
int belowIdx = (y + 1) * COLS + x;
|
||||
if (board[belowIdx] == 0) {
|
||||
// Move asteroid down one cell
|
||||
board[belowIdx] = board[idx];
|
||||
asteroidGrid[belowIdx] = asteroidGrid[idx];
|
||||
board[idx] = 0;
|
||||
asteroidGrid[idx].reset();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (moved);
|
||||
}
|
||||
|
||||
bool Game::tryMoveDown() {
|
||||
Piece p = cur; p.y += 1; if (!collides(p)) { cur = p; return true; } return false;
|
||||
|
||||
@ -7,12 +7,26 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <SDL3/SDL.h>
|
||||
#include "../../core/GravityManager.h"
|
||||
|
||||
enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
|
||||
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
||||
|
||||
// Game runtime mode
|
||||
enum class GameMode { Endless, Cooperate, Challenge };
|
||||
|
||||
// Special obstacle blocks used by Challenge mode
|
||||
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
||||
|
||||
struct AsteroidCell {
|
||||
AsteroidType type{AsteroidType::Normal};
|
||||
uint8_t hitsRemaining{1};
|
||||
bool gravityEnabled{false};
|
||||
uint8_t visualState{0};
|
||||
};
|
||||
|
||||
class Game {
|
||||
public:
|
||||
static constexpr int COLS = 10;
|
||||
@ -21,8 +35,10 @@ public:
|
||||
|
||||
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{3}; int y{-2}; };
|
||||
|
||||
explicit Game(int startLevel = 0) { reset(startLevel); }
|
||||
explicit Game(int startLevel = 0, GameMode mode = GameMode::Endless) : mode(mode) { reset(startLevel); }
|
||||
void reset(int startLevel = 0);
|
||||
void startChallengeRun(int startingLevel = 1); // resets stats and starts challenge level 1 (or provided)
|
||||
void beginNextChallengeLevel(); // advances to the next challenge level preserving score/time
|
||||
|
||||
// Simulation -----------------------------------------------------------
|
||||
void tickGravity(double frameMs); // advance gravity accumulator & drop
|
||||
@ -42,13 +58,26 @@ public:
|
||||
bool isGameOver() const { return gameOver; }
|
||||
bool isPaused() const { return paused; }
|
||||
void setPaused(bool p);
|
||||
GameMode getMode() const { return mode; }
|
||||
void setMode(GameMode m) { mode = m; }
|
||||
int score() const { return _score; }
|
||||
int lines() const { return _lines; }
|
||||
int level() const { return _level; }
|
||||
int challengeLevel() const { return challengeLevelIndex; }
|
||||
int asteroidsRemaining() const { return asteroidsRemainingCount; }
|
||||
int asteroidsTotal() const { return asteroidsTotalThisLevel; }
|
||||
bool isChallengeComplete() const { return challengeComplete; }
|
||||
bool isChallengeLevelActive() const { return challengeLevelActive; }
|
||||
bool isChallengeAdvanceQueued() const { return challengeAdvanceQueued; }
|
||||
int queuedChallengeLevel() const { return challengeQueuedLevel; }
|
||||
int consumeQueuedChallengeLevel(); // returns next level if queued, else 0
|
||||
int startLevelBase() const { return startLevel; }
|
||||
double elapsed() const; // Now calculated from start time
|
||||
void updateElapsedTime(); // Update elapsed time from system clock
|
||||
bool isSoftDropping() const { return softDropping; }
|
||||
const std::array<std::optional<AsteroidCell>, COLS*ROWS>& asteroidCells() const { return asteroidGrid; }
|
||||
const std::vector<SDL_Point>& getRecentAsteroidExplosions() const { return recentAsteroidExplosions; }
|
||||
void clearRecentAsteroidExplosions() { recentAsteroidExplosions.clear(); }
|
||||
|
||||
// Block statistics
|
||||
const std::array<int, PIECE_COUNT>& getBlockCounts() const { return blockCounts; }
|
||||
@ -61,8 +90,10 @@ public:
|
||||
// Sound effect callbacks
|
||||
using SoundCallback = std::function<void(int)>; // Callback for line clear sounds (number of lines)
|
||||
using LevelUpCallback = std::function<void(int)>; // Callback for level up sounds
|
||||
using AsteroidDestroyedCallback = std::function<void(AsteroidType)>; // Callback when an asteroid is fully destroyed
|
||||
void setSoundCallback(SoundCallback callback) { soundCallback = callback; }
|
||||
void setLevelUpCallback(LevelUpCallback callback) { levelUpCallback = callback; }
|
||||
void setAsteroidDestroyedCallback(AsteroidDestroyedCallback callback) { asteroidDestroyedCallback = callback; }
|
||||
|
||||
// Shape helper --------------------------------------------------------
|
||||
static bool cellFilled(const Piece& p, int cx, int cy);
|
||||
@ -87,6 +118,9 @@ public:
|
||||
int comboCount() const { return _comboCount; }
|
||||
|
||||
private:
|
||||
static constexpr int ASTEROID_BASE = 100; // sentinel offset for board encoding
|
||||
static constexpr int ASTEROID_MAX_LEVEL = 100;
|
||||
|
||||
std::array<int, COLS*ROWS> board{}; // 0 empty else color index
|
||||
Piece cur{}, hold{}, nextPiece{}; // current, held & next piece
|
||||
bool canHold{true};
|
||||
@ -117,6 +151,7 @@ private:
|
||||
// Sound effect callbacks
|
||||
SoundCallback soundCallback;
|
||||
LevelUpCallback levelUpCallback;
|
||||
AsteroidDestroyedCallback asteroidDestroyedCallback;
|
||||
// Gravity tuning -----------------------------------------------------
|
||||
// Global multiplier applied to all level timings (use to slow/speed whole-game gravity)
|
||||
double gravityGlobalMultiplier{1.0};
|
||||
@ -132,6 +167,34 @@ private:
|
||||
uint32_t hardDropFxId{0};
|
||||
uint64_t pieceSequence{0};
|
||||
|
||||
// Challenge mode state -------------------------------------------------
|
||||
GameMode mode{GameMode::Endless};
|
||||
int challengeLevelIndex{1};
|
||||
int asteroidsRemainingCount{0};
|
||||
int asteroidsTotalThisLevel{0};
|
||||
bool challengeComplete{false};
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS> asteroidGrid{};
|
||||
uint32_t challengeSeedBase{0};
|
||||
std::mt19937 challengeRng{ std::random_device{}() };
|
||||
bool challengeLevelActive{false};
|
||||
bool challengeAdvanceQueued{false};
|
||||
int challengeQueuedLevel{0};
|
||||
// Asteroid SFX latency mitigation
|
||||
std::optional<AsteroidType> pendingAsteroidDestroyType;
|
||||
bool asteroidDestroySoundPreplayed{false};
|
||||
|
||||
// Recent asteroid explosion positions (grid coords) for renderer FX
|
||||
std::vector<SDL_Point> recentAsteroidExplosions;
|
||||
|
||||
// Expose the internal challenge seed base for deterministic FX/RNG coordination
|
||||
public:
|
||||
uint32_t getChallengeSeedBase() const { return challengeSeedBase; }
|
||||
|
||||
// Helpers for board encoding of asteroids
|
||||
static bool isAsteroidValue(int boardValue);
|
||||
static AsteroidType asteroidTypeFromValue(int boardValue);
|
||||
static int asteroidBoardValue(AsteroidType t);
|
||||
|
||||
// Internal helpers ----------------------------------------------------
|
||||
void refillBag();
|
||||
void spawn();
|
||||
@ -140,5 +203,14 @@ private:
|
||||
int checkLines(); // Find completed lines and store them
|
||||
void actualClearLines(); // Actually remove lines from board
|
||||
bool tryMoveDown(); // one-row fall; returns true if moved
|
||||
void clearAsteroidGrid();
|
||||
void setupChallengeLevel(int level, bool preserveStats);
|
||||
void placeAsteroidsForLevel(int level);
|
||||
AsteroidType chooseAsteroidTypeForLevel(int level);
|
||||
AsteroidCell makeAsteroidForType(AsteroidType t) const;
|
||||
void handleAsteroidsOnClearedRows(const std::vector<int>& clearedRows,
|
||||
std::array<int, COLS*ROWS>& outBoard,
|
||||
std::array<std::optional<AsteroidCell>, COLS*ROWS>& outAsteroids);
|
||||
void applyAsteroidGravity();
|
||||
// Gravity tuning helpers (public API declared above)
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/AudioManager.h"
|
||||
#include "gameplay/core/Game.h"
|
||||
|
||||
#ifndef M_PI
|
||||
@ -188,10 +189,13 @@ void LineEffect::initAudio() {
|
||||
}
|
||||
}
|
||||
|
||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize) {
|
||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols, int gapPx, int gapAfterCol) {
|
||||
if (rows.empty()) return;
|
||||
|
||||
clearingRows = rows;
|
||||
effectGridCols = std::max(1, gridCols);
|
||||
effectGapPx = std::max(0, gapPx);
|
||||
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||
state = AnimationState::FLASH_WHITE;
|
||||
timer = 0.0f;
|
||||
dropProgress = 0.0f;
|
||||
@ -228,8 +232,11 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
|
||||
|
||||
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
|
||||
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
|
||||
for (int col = 0; col < Game::COLS; ++col) {
|
||||
for (int col = 0; col < effectGridCols; ++col) {
|
||||
float centerX = gridX + col * blockSize + blockSize * 0.5f;
|
||||
if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) {
|
||||
centerX += static_cast<float>(effectGapPx);
|
||||
}
|
||||
SDL_Color tint = pickFireColor();
|
||||
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
|
||||
spawnShardBurst(centerX, centerY, tint);
|
||||
@ -337,9 +344,13 @@ void LineEffect::updateGlowPulses(float dt) {
|
||||
glowPulses.end());
|
||||
}
|
||||
|
||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) {
|
||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx, int gapAfterCol) {
|
||||
if (state == AnimationState::IDLE) return;
|
||||
|
||||
// Allow caller to override gap mapping (useful for Coop renderer that inserts a mid-gap).
|
||||
effectGapPx = std::max(0, gapPx);
|
||||
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||
|
||||
switch (state) {
|
||||
case AnimationState::FLASH_WHITE:
|
||||
renderFlash(gridX, gridY, blockSize);
|
||||
@ -383,10 +394,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
|
||||
|
||||
for (int row : clearingRows) {
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
|
||||
const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0;
|
||||
SDL_FRect flashRect = {
|
||||
static_cast<float>(gridX - 4),
|
||||
static_cast<float>(gridY + row * blockSize - 4),
|
||||
static_cast<float>(10 * blockSize + 8),
|
||||
static_cast<float>(effectGridCols * blockSize + gapW + 8),
|
||||
static_cast<float>(blockSize + 8)
|
||||
};
|
||||
SDL_RenderFillRect(renderer, &flashRect);
|
||||
@ -450,7 +462,7 @@ void LineEffect::playLineClearSound(int lineCount) {
|
||||
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
|
||||
if (sample && !sample->empty()) {
|
||||
// Mix via shared Audio device so it layers with music
|
||||
Audio::instance().playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
if (auto sys = AudioManager::get()) sys->playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -69,11 +69,11 @@ public:
|
||||
void shutdown();
|
||||
|
||||
// Start line clear effect for the specified rows
|
||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize);
|
||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS, int gapPx = 0, int gapAfterCol = 0);
|
||||
|
||||
// Update and render the effect
|
||||
bool update(float deltaTime); // Returns true if effect is complete
|
||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize);
|
||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx = 0, int gapAfterCol = 0);
|
||||
float getRowDropOffset(int row) const;
|
||||
|
||||
// Audio
|
||||
@ -120,4 +120,7 @@ private:
|
||||
std::array<float, Game::ROWS> rowDropTargets{};
|
||||
float dropProgress = 0.0f;
|
||||
int dropBlockSize = 0;
|
||||
int effectGridCols = Game::COLS;
|
||||
int effectGapPx = 0;
|
||||
int effectGapAfterCol = 0;
|
||||
};
|
||||
|
||||
@ -116,6 +116,169 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the hold panel (extracted for readability).
|
||||
static void drawHoldPanel(SDL_Renderer* renderer,
|
||||
Game* game,
|
||||
FontAtlas* pixelFont,
|
||||
SDL_Texture* blocksTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
float scoreX,
|
||||
float statsW,
|
||||
float gridY,
|
||||
float finalBlockSize,
|
||||
float statsY,
|
||||
float statsH) {
|
||||
float holdBlockH = (finalBlockSize * 0.6f) * 4.0f;
|
||||
// Base panel height; enforce minimum but allow larger to fit texture
|
||||
float panelH = std::max(holdBlockH + 12.0f, 420.0f);
|
||||
// Increase height by ~20% of the hold block to give more vertical room
|
||||
float extraH = holdBlockH * 0.20f;
|
||||
panelH += extraH;
|
||||
|
||||
const float holdGap = 18.0f;
|
||||
|
||||
// Align X to the bottom score label (`scoreX`) plus an offset to the right
|
||||
float panelX = scoreX + 30.0f; // move ~30px right to align with score label
|
||||
float panelW = statsW + 32.0f;
|
||||
float panelY = gridY - panelH - holdGap;
|
||||
// Move panel a bit higher for spacing (about half the extra height)
|
||||
panelY -= extraH * 0.5f;
|
||||
float labelX = panelX + 40.0f; // shift HOLD label ~30px to the right
|
||||
float labelY = panelY + 8.0f;
|
||||
|
||||
if (holdPanelTex) {
|
||||
int texW = 0, texH = 0;
|
||||
SDL_QueryTexture(holdPanelTex, nullptr, nullptr, &texW, &texH);
|
||||
if (texW > 0 && texH > 0) {
|
||||
// Fill panel width and compute destination height from texture aspect ratio
|
||||
float texAspect = float(texH) / float(texW);
|
||||
float dstW = panelW;
|
||||
float dstH = dstW * texAspect;
|
||||
// If texture height exceeds panel, expand panelH to fit texture comfortably
|
||||
if (dstH + 12.0f > panelH) {
|
||||
panelH = dstH + 12.0f;
|
||||
panelY = gridY - panelH - holdGap;
|
||||
labelY = panelY + 8.0f;
|
||||
}
|
||||
float dstX = panelX;
|
||||
float dstY = panelY + (panelH - dstH) * 0.5f;
|
||||
|
||||
SDL_FRect panelDst{dstX, dstY, dstW, dstH};
|
||||
SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR);
|
||||
SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst);
|
||||
} else {
|
||||
// Fallback to filling panel area if texture metrics unavailable
|
||||
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||
SDL_RenderFillRect(renderer, &panelDst);
|
||||
}
|
||||
} else {
|
||||
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||
SDL_RenderFillRect(renderer, &panelDst);
|
||||
}
|
||||
|
||||
pixelFont->draw(renderer, labelX, labelY, "HOLD", 1.0f, {255, 220, 0, 255});
|
||||
|
||||
if (game->held().type < PIECE_COUNT) {
|
||||
float previewW = finalBlockSize * 0.6f * 4.0f;
|
||||
float previewX = panelX + (panelW - previewW) * 0.5f;
|
||||
float previewY = panelY + (panelH - holdBlockH) * 0.5f;
|
||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw next piece panel (border/texture + preview)
|
||||
static void drawNextPanel(SDL_Renderer* renderer,
|
||||
FontAtlas* pixelFont,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* blocksTex,
|
||||
Game* game,
|
||||
float nextX,
|
||||
float nextY,
|
||||
float nextW,
|
||||
float nextH,
|
||||
float contentOffsetX,
|
||||
float contentOffsetY,
|
||||
float finalBlockSize) {
|
||||
if (nextPanelTex) {
|
||||
SDL_FRect dst{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH };
|
||||
SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND);
|
||||
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst);
|
||||
} else {
|
||||
// Draw bordered panel as before
|
||||
SDL_SetRenderDrawColor(renderer, 100, 120, 200, 255);
|
||||
SDL_FRect outer{ nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6 };
|
||||
SDL_RenderFillRect(renderer, &outer);
|
||||
SDL_SetRenderDrawColor(renderer, 30, 35, 50, 255);
|
||||
SDL_FRect inner{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH };
|
||||
SDL_RenderFillRect(renderer, &inner);
|
||||
}
|
||||
|
||||
// Label and small preview
|
||||
pixelFont->draw(renderer, nextX + 10, nextY - 20, "NEXT", 1.0f, {255, 220, 0, 255});
|
||||
if (game->next().type < PIECE_COUNT) {
|
||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->next().type), nextX + 10, nextY + 5, finalBlockSize * 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw score panel (right side)
|
||||
static void drawScorePanel(SDL_Renderer* renderer,
|
||||
FontAtlas* pixelFont,
|
||||
Game* game,
|
||||
float scoreX,
|
||||
float gridY,
|
||||
float GRID_H,
|
||||
float finalBlockSize) {
|
||||
const float contentTopOffset = 0.0f;
|
||||
const float contentBottomOffset = 290.0f;
|
||||
const float contentPad = 36.0f;
|
||||
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
|
||||
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
|
||||
|
||||
pixelFont->draw(renderer, scoreX, baseY + 0, "SCORE", 1.0f, {255, 220, 0, 255});
|
||||
char scoreStr[32];
|
||||
snprintf(scoreStr, sizeof(scoreStr), "%d", game->score());
|
||||
pixelFont->draw(renderer, scoreX, baseY + 25, scoreStr, 0.9f, {255, 255, 255, 255});
|
||||
|
||||
pixelFont->draw(renderer, scoreX, baseY + 70, "LINES", 1.0f, {255, 220, 0, 255});
|
||||
char linesStr[16];
|
||||
snprintf(linesStr, sizeof(linesStr), "%03d", game->lines());
|
||||
pixelFont->draw(renderer, scoreX, baseY + 95, linesStr, 0.9f, {255, 255, 255, 255});
|
||||
|
||||
pixelFont->draw(renderer, scoreX, baseY + 140, "LEVEL", 1.0f, {255, 220, 0, 255});
|
||||
char levelStr[16];
|
||||
snprintf(levelStr, sizeof(levelStr), "%02d", game->level());
|
||||
pixelFont->draw(renderer, scoreX, baseY + 165, levelStr, 0.9f, {255, 255, 255, 255});
|
||||
|
||||
// Next level progress
|
||||
int startLv = game->startLevelBase();
|
||||
int firstThreshold = (startLv + 1) * 10;
|
||||
int linesDone = game->lines();
|
||||
int nextThreshold = 0;
|
||||
if (linesDone < firstThreshold) {
|
||||
nextThreshold = firstThreshold;
|
||||
} else {
|
||||
int blocksPast = linesDone - firstThreshold;
|
||||
nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
|
||||
}
|
||||
int linesForNext = std::max(0, nextThreshold - linesDone);
|
||||
pixelFont->draw(renderer, scoreX, baseY + 200, "NEXT LVL", 1.0f, {255, 220, 0, 255});
|
||||
char nextStr[32];
|
||||
snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
|
||||
pixelFont->draw(renderer, scoreX, baseY + 225, nextStr, 0.9f, {80, 255, 120, 255});
|
||||
|
||||
// Time display
|
||||
pixelFont->draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255});
|
||||
int totalSecs = static_cast<int>(game->elapsed());
|
||||
int mins = totalSecs / 60;
|
||||
int secs = totalSecs % 60;
|
||||
char timeStr[16];
|
||||
snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
|
||||
pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255});
|
||||
}
|
||||
|
||||
void GameRenderer::renderPlayingState(
|
||||
SDL_Renderer* renderer,
|
||||
Game* game,
|
||||
@ -125,6 +288,7 @@ void GameRenderer::renderPlayingState(
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
@ -164,64 +328,8 @@ void GameRenderer::renderPlayingState(
|
||||
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;
|
||||
|
||||
const float maxBlockSizeW = availableWidth / Game::COLS;
|
||||
const float maxBlockSizeH = availableHeight / Game::ROWS;
|
||||
float previewY = rowTop - 4.0f;
|
||||
const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f));
|
||||
|
||||
const float GRID_W = Game::COLS * finalBlockSize;
|
||||
const float GRID_H = Game::ROWS * finalBlockSize;
|
||||
|
||||
// Calculate positions
|
||||
const float totalContentHeight = NEXT_PIECE_HEIGHT + GRID_H;
|
||||
const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
|
||||
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
|
||||
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
|
||||
|
||||
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
|
||||
const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
|
||||
float barY = previewY + previewSize + 10.0f;
|
||||
const float statsX = layoutStartX + contentOffsetX;
|
||||
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
|
||||
const float scoreX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + contentOffsetX;
|
||||
float rowBottom = percY + 14.0f;
|
||||
SDL_FRect rowBg{
|
||||
previewX - 10.0f,
|
||||
rowTop - 8.0f,
|
||||
rowWidth + 20.0f,
|
||||
rowBottom - rowTop
|
||||
};
|
||||
const float nextW = finalBlockSize * 4 + 20;
|
||||
const float nextH = finalBlockSize * 2 + 20;
|
||||
const float nextX = gridX + (GRID_W - nextW) * 0.5f;
|
||||
const float nextY = contentStartY + contentOffsetY;
|
||||
|
||||
// Handle line clearing effects
|
||||
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||
auto completedLines = game->getCompletedLines();
|
||||
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
||||
}
|
||||
|
||||
// Draw styled game grid border and semi-transparent background so the scene shows through.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
// Outer glow layers (subtle, increasing spread, decreasing alpha)
|
||||
drawRectWithOffset(gridX - 8 - contentOffsetX, gridY - 8 - contentOffsetY, GRID_W + 16, GRID_H + 16, {100, 120, 200, 28});
|
||||
drawRectWithOffset(gridX - 6 - contentOffsetX, gridY - 6 - contentOffsetY, GRID_W + 12, GRID_H + 12, {100, 120, 200, 40});
|
||||
|
||||
// Accent border (brighter, thin)
|
||||
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 220});
|
||||
drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - 1 - contentOffsetY, GRID_W + 2, GRID_H + 2, {60, 80, 160, 200});
|
||||
|
||||
// Do NOT fill the interior of the grid so the background shows through.
|
||||
// (Intentionally leave the playfield interior transparent.)
|
||||
|
||||
// Draw panel backgrounds
|
||||
SDL_SetRenderDrawColor(renderer, 10, 15, 25, 160);
|
||||
SDL_FRect lbg{statsX - 16, gridY - 10, statsW + 32, GRID_H + 20};
|
||||
SDL_RenderFillRect(renderer, &lbg);
|
||||
|
||||
SDL_FRect rbg{scoreX - 16, gridY - 16, statsW + 32, GRID_H + 32};
|
||||
SDL_RenderFillRect(renderer, &rbg);
|
||||
// Draw hold panel via helper
|
||||
drawHoldPanel(renderer, game, pixelFont, blocksTex, holdPanelTex, scoreX, statsW, gridY, finalBlockSize, statsY, statsH);
|
||||
|
||||
// Draw grid lines (solid so grid remains legible over background)
|
||||
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
|
||||
@ -244,17 +352,8 @@ void GameRenderer::renderPlayingState(
|
||||
drawRectWithOffset(statsX - 3 - contentOffsetX, statsY - 3 - contentOffsetY, statsW + 6, statsH + 6, {100, 120, 200, 255});
|
||||
drawRectWithOffset(statsX - contentOffsetX, statsY - contentOffsetY, statsW, statsH, {30, 35, 50, 255});
|
||||
|
||||
// Draw next piece preview panel border
|
||||
// If a NEXT panel texture was provided, draw it instead of the custom
|
||||
// background/outline. The texture will be scaled to fit the panel area.
|
||||
if (nextPanelTex) {
|
||||
SDL_FRect dst{ nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH };
|
||||
SDL_SetTextureBlendMode(nextPanelTex, SDL_BLENDMODE_BLEND);
|
||||
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst);
|
||||
} else {
|
||||
drawRectWithOffset(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255});
|
||||
drawRectWithOffset(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255});
|
||||
}
|
||||
// Draw next piece panel
|
||||
drawNextPanel(renderer, pixelFont, nextPanelTex, blocksTex, game, nextX, nextY, nextW, nextH, contentOffsetX, contentOffsetY, finalBlockSize);
|
||||
|
||||
// Draw the game board
|
||||
const auto &board = game->boardRef();
|
||||
@ -411,53 +510,8 @@ void GameRenderer::renderPlayingState(
|
||||
yCursor = rowBottom + rowSpacing;
|
||||
}
|
||||
|
||||
// Draw score panel (right side)
|
||||
const float contentTopOffset = 0.0f;
|
||||
const float contentBottomOffset = 290.0f;
|
||||
const float contentPad = 36.0f;
|
||||
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
|
||||
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
|
||||
|
||||
pixelFont->draw(renderer, scoreX, baseY + 0, "SCORE", 1.0f, {255, 220, 0, 255});
|
||||
char scoreStr[32];
|
||||
snprintf(scoreStr, sizeof(scoreStr), "%d", game->score());
|
||||
pixelFont->draw(renderer, scoreX, baseY + 25, scoreStr, 0.9f, {255, 255, 255, 255});
|
||||
|
||||
pixelFont->draw(renderer, scoreX, baseY + 70, "LINES", 1.0f, {255, 220, 0, 255});
|
||||
char linesStr[16];
|
||||
snprintf(linesStr, sizeof(linesStr), "%03d", game->lines());
|
||||
pixelFont->draw(renderer, scoreX, baseY + 95, linesStr, 0.9f, {255, 255, 255, 255});
|
||||
|
||||
pixelFont->draw(renderer, scoreX, baseY + 140, "LEVEL", 1.0f, {255, 220, 0, 255});
|
||||
char levelStr[16];
|
||||
snprintf(levelStr, sizeof(levelStr), "%02d", game->level());
|
||||
pixelFont->draw(renderer, scoreX, baseY + 165, levelStr, 0.9f, {255, 255, 255, 255});
|
||||
|
||||
// Next level progress
|
||||
int startLv = game->startLevelBase();
|
||||
int firstThreshold = (startLv + 1) * 10;
|
||||
int linesDone = game->lines();
|
||||
int nextThreshold = 0;
|
||||
if (linesDone < firstThreshold) {
|
||||
nextThreshold = firstThreshold;
|
||||
} else {
|
||||
int blocksPast = linesDone - firstThreshold;
|
||||
nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
|
||||
}
|
||||
int linesForNext = std::max(0, nextThreshold - linesDone);
|
||||
pixelFont->draw(renderer, scoreX, baseY + 200, "NEXT LVL", 1.0f, {255, 220, 0, 255});
|
||||
char nextStr[32];
|
||||
snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
|
||||
pixelFont->draw(renderer, scoreX, baseY + 225, nextStr, 0.9f, {80, 255, 120, 255});
|
||||
|
||||
// Time display
|
||||
pixelFont->draw(renderer, scoreX, baseY + 265, "TIME", 1.0f, {255, 220, 0, 255});
|
||||
int totalSecs = static_cast<int>(game->elapsed());
|
||||
int mins = totalSecs / 60;
|
||||
int secs = totalSecs % 60;
|
||||
char timeStr[16];
|
||||
snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
|
||||
pixelFont->draw(renderer, scoreX, baseY + 290, timeStr, 0.9f, {255, 255, 255, 255});
|
||||
// Draw score panel
|
||||
drawScorePanel(renderer, pixelFont, game, scoreX, gridY, GRID_H, finalBlockSize);
|
||||
|
||||
// Gravity HUD
|
||||
char gms[64];
|
||||
@ -466,10 +520,76 @@ void GameRenderer::renderPlayingState(
|
||||
snprintf(gms, sizeof(gms), "GRAV: %.0f ms (%.2f fps)", gms_val, gfps);
|
||||
pixelFont->draw(renderer, logicalW - 260, 10, gms, 0.9f, {200, 200, 220, 255});
|
||||
|
||||
// Hold piece (if implemented)
|
||||
// Hold panel (always visible): draw background & label; preview shown only when a piece is held.
|
||||
{
|
||||
float holdBlockH = (finalBlockSize * 0.6f) * 4.0f;
|
||||
// Base panel height; enforce minimum but allow larger to fit texture
|
||||
float panelH = std::max(holdBlockH + 12.0f, 420.0f);
|
||||
// Increase height by ~20% of the hold block to give more vertical room
|
||||
float extraH = holdBlockH * 0.50f;
|
||||
panelH += extraH;
|
||||
const float holdGap = 18.0f;
|
||||
|
||||
// Align X to the bottom score label (`scoreX`) plus an offset to the right
|
||||
float panelX = scoreX + 30.0f; // move ~30px right to align with score label
|
||||
float panelW = statsW + 32.0f;
|
||||
float panelY = gridY - panelH - holdGap;
|
||||
// Move panel a bit higher for spacing (about half the extra height)
|
||||
panelY -= extraH * 0.5f;
|
||||
float labelX = panelX + 40.0f; // shift HOLD label ~30px to the right
|
||||
float labelY = panelY + 8.0f;
|
||||
|
||||
if (holdPanelTex) {
|
||||
int texW = 0, texH = 0;
|
||||
SDL_QueryTexture(holdPanelTex, nullptr, nullptr, &texW, &texH);
|
||||
if (texW > 0 && texH > 0) {
|
||||
// If the texture is taller than the current panel, expand panelH
|
||||
float texAspect = float(texH) / float(texW);
|
||||
float desiredTexH = panelW * texAspect;
|
||||
if (desiredTexH + 12.0f > panelH) {
|
||||
panelH = desiredTexH + 12.0f;
|
||||
// Recompute vertical placement after growing panelH
|
||||
panelY = gridY - panelH - holdGap;
|
||||
labelY = panelY + 8.0f;
|
||||
}
|
||||
|
||||
// Fill panel width and compute destination height from texture aspect ratio
|
||||
float texAspect = float(texH) / float(texW);
|
||||
float dstW = panelW;
|
||||
float dstH = dstW * texAspect * 1.2f;
|
||||
// If texture height exceeds panel, expand panelH to fit texture comfortably
|
||||
if (dstH + 12.0f > panelH) {
|
||||
panelH = dstH + 12.0f;
|
||||
panelY = gridY - panelH - holdGap;
|
||||
labelY = panelY + 8.0f;
|
||||
}
|
||||
float dstX = panelX;
|
||||
float dstY = panelY + (panelH - dstH) * 0.5f;
|
||||
|
||||
SDL_FRect panelDst{dstX, dstY, dstW, dstH};
|
||||
SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR);
|
||||
SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst);
|
||||
} else {
|
||||
// Fallback to filling panel area if texture metrics unavailable
|
||||
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||
SDL_RenderFillRect(renderer, &panelDst);
|
||||
}
|
||||
} else {
|
||||
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220);
|
||||
SDL_FRect panelDst{panelX, panelY, panelW, panelH};
|
||||
SDL_RenderFillRect(renderer, &panelDst);
|
||||
}
|
||||
|
||||
pixelFont->draw(renderer, labelX, labelY, "HOLD", 1.0f, {255, 220, 0, 255});
|
||||
|
||||
if (game->held().type < PIECE_COUNT) {
|
||||
pixelFont->draw(renderer, statsX + 10, statsY + statsH - 80, "HOLD", 1.0f, {255, 220, 0, 255});
|
||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), statsX + 60, statsY + statsH - 80, finalBlockSize * 0.6f);
|
||||
float previewW = finalBlockSize * 0.6f * 4.0f;
|
||||
float previewX = panelX + (panelW - previewW) * 0.5f;
|
||||
float previewY = panelY + (panelH - holdBlockH) * 0.5f;
|
||||
drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
// Pause overlay (suppressed when requested, e.g., countdown)
|
||||
|
||||
@ -24,6 +24,7 @@ public:
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
|
||||
@ -84,7 +84,7 @@ void RenderManager::beginFrame() {
|
||||
|
||||
// Trace beginFrame entry
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
||||
}
|
||||
|
||||
// Clear the screen (wrapped with trace)
|
||||
@ -92,7 +92,7 @@ void RenderManager::beginFrame() {
|
||||
|
||||
// Trace after clear
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,14 +102,14 @@ void RenderManager::endFrame() {
|
||||
}
|
||||
// Trace before present
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
||||
}
|
||||
|
||||
SDL_RenderPresent(m_renderer);
|
||||
|
||||
// Trace after present
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,11 +170,11 @@ void RenderManager::renderTexture(SDL_Texture* texture, const SDL_FRect* src, co
|
||||
|
||||
// Trace renderTexture usage
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
||||
}
|
||||
SDL_RenderTexture(m_renderer, texture, src, dst);
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -107,8 +107,22 @@ void SpaceWarp::spawnComet() {
|
||||
float normalizedAspect = std::max(aspect, MIN_ASPECT);
|
||||
float xRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? aspect : 1.0f);
|
||||
float yRange = settings.baseSpawnRange * 1.2f * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect));
|
||||
// Avoid spawning comets exactly on (or extremely near) the view axis,
|
||||
// which can project to a nearly static bright dot.
|
||||
const float axisMinFrac = 0.06f;
|
||||
bool axisOk = false;
|
||||
for (int attempt = 0; attempt < 10 && !axisOk; ++attempt) {
|
||||
comet.x = randomRange(-xRange, xRange);
|
||||
comet.y = randomRange(-yRange, yRange);
|
||||
float nx = comet.x / std::max(xRange, 0.0001f);
|
||||
float ny = comet.y / std::max(yRange, 0.0001f);
|
||||
axisOk = (nx * nx + ny * ny) >= (axisMinFrac * axisMinFrac);
|
||||
}
|
||||
if (!axisOk) {
|
||||
float ang = randomRange(0.0f, 6.28318530718f);
|
||||
comet.x = std::cos(ang) * xRange * axisMinFrac;
|
||||
comet.y = std::sin(ang) * yRange * axisMinFrac;
|
||||
}
|
||||
comet.z = randomRange(minDepth + 4.0f, maxDepth);
|
||||
float baseSpeed = randomRange(settings.minSpeed, settings.maxSpeed);
|
||||
float multiplier = randomRange(settings.cometSpeedMultiplierMin, settings.cometSpeedMultiplierMax);
|
||||
@ -154,9 +168,24 @@ void SpaceWarp::respawn(WarpStar& star, bool randomDepth) {
|
||||
float normalizedAspect = std::max(aspect, MIN_ASPECT);
|
||||
float xRange = settings.baseSpawnRange * (aspect >= 1.0f ? aspect : 1.0f);
|
||||
float yRange = settings.baseSpawnRange * (aspect >= 1.0f ? 1.0f : (1.0f / normalizedAspect));
|
||||
// Avoid axis-aligned stars (x≈0,y≈0) which can project to a static, bright center dot.
|
||||
const float axisMinFrac = 0.06f;
|
||||
bool axisOk = false;
|
||||
for (int attempt = 0; attempt < 10 && !axisOk; ++attempt) {
|
||||
star.x = randomRange(-xRange, xRange);
|
||||
star.y = randomRange(-yRange, yRange);
|
||||
star.z = randomDepth ? randomRange(minDepth, maxDepth) : maxDepth;
|
||||
float nx = star.x / std::max(xRange, 0.0001f);
|
||||
float ny = star.y / std::max(yRange, 0.0001f);
|
||||
axisOk = (nx * nx + ny * ny) >= (axisMinFrac * axisMinFrac);
|
||||
}
|
||||
if (!axisOk) {
|
||||
float ang = randomRange(0.0f, 6.28318530718f);
|
||||
star.x = std::cos(ang) * xRange * axisMinFrac;
|
||||
star.y = std::sin(ang) * yRange * axisMinFrac;
|
||||
}
|
||||
|
||||
// Keep z slightly above minDepth so projection never starts from the exact singular plane.
|
||||
star.z = randomDepth ? randomRange(minDepth + 0.25f, maxDepth) : maxDepth;
|
||||
star.speed = randomRange(settings.minSpeed, settings.maxSpeed);
|
||||
star.shade = randomRange(settings.minShade, settings.maxShade);
|
||||
static constexpr Uint8 GRAY_SHADES[] = {160, 180, 200, 220, 240};
|
||||
@ -253,6 +282,13 @@ void SpaceWarp::update(float deltaSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If a star projects to (near) the visual center, it can appear perfectly static
|
||||
// during straight-line flight. Replace it to avoid the "big static star" artifact.
|
||||
if (std::abs(sx - centerX) < 1.25f && std::abs(sy - centerY) < 1.25f) {
|
||||
respawn(star, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
star.prevScreenX = star.screenX;
|
||||
star.prevScreenY = star.screenY;
|
||||
star.screenX = sx;
|
||||
|
||||
@ -69,8 +69,23 @@ void Starfield3D::setRandomDirection(Star3D& star) {
|
||||
void Starfield3D::updateStar(int index) {
|
||||
Star3D& star = stars[index];
|
||||
|
||||
star.x = randomFloat(-25.0f, 25.0f);
|
||||
star.y = randomFloat(-25.0f, 25.0f);
|
||||
// Avoid spawning stars on (or very near) the view axis. A star with x≈0 and y≈0
|
||||
// projects to the exact center, and when it happens to be bright it looks like a
|
||||
// static "big" star.
|
||||
constexpr float SPAWN_RANGE = 25.0f;
|
||||
constexpr float MIN_AXIS_RADIUS = 2.5f; // in star-space units
|
||||
for (int attempt = 0; attempt < 8; ++attempt) {
|
||||
star.x = randomFloat(-SPAWN_RANGE, SPAWN_RANGE);
|
||||
star.y = randomFloat(-SPAWN_RANGE, SPAWN_RANGE);
|
||||
if ((star.x * star.x + star.y * star.y) >= (MIN_AXIS_RADIUS * MIN_AXIS_RADIUS)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If we somehow still ended up too close, push it out deterministically.
|
||||
if ((star.x * star.x + star.y * star.y) < (MIN_AXIS_RADIUS * MIN_AXIS_RADIUS)) {
|
||||
star.x = (star.x < 0.0f ? -1.0f : 1.0f) * MIN_AXIS_RADIUS;
|
||||
star.y = (star.y < 0.0f ? -1.0f : 1.0f) * MIN_AXIS_RADIUS;
|
||||
}
|
||||
star.z = randomFloat(1.0f, MAX_DEPTH);
|
||||
|
||||
// Give stars initial velocities in all possible directions
|
||||
@ -92,6 +107,15 @@ void Starfield3D::updateStar(int index) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure newly spawned stars have some lateral drift so they don't appear to
|
||||
// "stick" near the center line.
|
||||
if (std::abs(star.vx) < 0.02f && std::abs(star.vy) < 0.02f) {
|
||||
const float sx = (star.x < 0.0f ? -1.0f : 1.0f);
|
||||
const float sy = (star.y < 0.0f ? -1.0f : 1.0f);
|
||||
star.vx = sx * randomFloat(0.04f, 0.14f);
|
||||
star.vy = sy * randomFloat(0.04f, 0.14f);
|
||||
}
|
||||
|
||||
star.targetVx = star.vx;
|
||||
star.targetVy = star.vy;
|
||||
star.targetVz = star.vz;
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include "../../gameplay/core/Game.h"
|
||||
#include "../../gameplay/coop/CoopGame.h"
|
||||
|
||||
// Forward declarations
|
||||
class FontAtlas;
|
||||
@ -21,14 +24,23 @@ public:
|
||||
FontAtlas* pixelFont,
|
||||
LineEffect* lineEffect,
|
||||
SDL_Texture* blocksTex,
|
||||
SDL_Texture* asteroidsTex,
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
bool countdownActive,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
float winW,
|
||||
float winH
|
||||
float winH,
|
||||
bool challengeClearFxActive = false,
|
||||
const std::vector<int>* challengeClearFxOrder = nullptr,
|
||||
double challengeClearFxElapsedMs = 0.0,
|
||||
double challengeClearFxDurationMs = 0.0,
|
||||
const std::string* challengeStoryText = nullptr,
|
||||
float challengeStoryAlpha = 0.0f
|
||||
);
|
||||
|
||||
// Render the pause overlay (full screen)
|
||||
@ -50,6 +62,24 @@ public:
|
||||
int selectedButton
|
||||
);
|
||||
|
||||
static void renderCoopPlayingState(
|
||||
SDL_Renderer* renderer,
|
||||
CoopGame* game,
|
||||
FontAtlas* pixelFont,
|
||||
LineEffect* lineEffect,
|
||||
SDL_Texture* blocksTex,
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
bool paused,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
float winW,
|
||||
float winH
|
||||
);
|
||||
|
||||
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
||||
// calling from non-member helper functions (e.g. visual effects) that cannot
|
||||
// access private class members.
|
||||
|
||||
@ -84,7 +84,7 @@ void RenderManager::beginFrame() {
|
||||
|
||||
// Trace beginFrame entry
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
||||
}
|
||||
|
||||
// Clear the screen (wrapped with trace)
|
||||
@ -92,7 +92,7 @@ void RenderManager::beginFrame() {
|
||||
|
||||
// Trace after clear
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,14 +102,14 @@ void RenderManager::endFrame() {
|
||||
}
|
||||
// Trace before present
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
||||
}
|
||||
|
||||
SDL_RenderPresent(m_renderer);
|
||||
|
||||
// Trace after present
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,11 +200,11 @@ void RenderManager::renderTexture(SDL_Texture* texture, const SDL_FRect* src, co
|
||||
|
||||
// Trace renderTexture usage
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
||||
}
|
||||
SDL_RenderTexture(m_renderer, texture, src, dst);
|
||||
{
|
||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
src/graphics/renderers/RenderPrimitives.h
Normal file
@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace RenderPrimitives {
|
||||
|
||||
inline void fillRect(SDL_Renderer* renderer, float x, float y, float w, float h, SDL_Color color) {
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
|
||||
SDL_FRect rect{x, y, w, h};
|
||||
SDL_RenderFillRect(renderer, &rect);
|
||||
}
|
||||
|
||||
} // namespace RenderPrimitives
|
||||
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
@ -0,0 +1,358 @@
|
||||
#include "SyncLineRenderer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
SyncLineRenderer::SyncLineRenderer()
|
||||
: m_state(SyncState::Idle),
|
||||
m_flashTimer(0.0f),
|
||||
m_time(0.0f) {
|
||||
m_particles.reserve(MAX_PARTICLES);
|
||||
}
|
||||
|
||||
static float syncWobbleX(float t) {
|
||||
// Small, smooth horizontal motion to make the conduit feel fluid.
|
||||
// Kept subtle so it doesn't distract from gameplay.
|
||||
return std::sinf(t * 2.1f) * 1.25f + std::sinf(t * 5.2f + 1.3f) * 0.55f;
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SpawnParticle() {
|
||||
if (m_particles.size() >= MAX_PARTICLES) {
|
||||
return;
|
||||
}
|
||||
|
||||
SyncParticle p;
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||
// Spawn around the beam center so it reads like a conduit.
|
||||
const float jitter = -8.0f + static_cast<float>(std::rand() % 17);
|
||||
|
||||
p.x = centerX + jitter;
|
||||
p.y = m_rect.y + m_rect.h + static_cast<float>(std::rand() % 10);
|
||||
|
||||
// Two styles: tiny sparkle dots + short streaks.
|
||||
const bool dot = (std::rand() % 100) < 35;
|
||||
if (dot) {
|
||||
p.vx = (-18.0f + static_cast<float>(std::rand() % 37));
|
||||
p.vy = 180.0f + static_cast<float>(std::rand() % 180);
|
||||
p.w = 1.0f + static_cast<float>(std::rand() % 2);
|
||||
p.h = 1.0f + static_cast<float>(std::rand() % 2);
|
||||
p.alpha = 240.0f;
|
||||
} else {
|
||||
p.vx = (-14.0f + static_cast<float>(std::rand() % 29));
|
||||
p.vy = 160.0f + static_cast<float>(std::rand() % 200);
|
||||
p.w = 1.0f + static_cast<float>(std::rand() % 3);
|
||||
p.h = 3.0f + static_cast<float>(std::rand() % 10);
|
||||
p.alpha = 220.0f;
|
||||
}
|
||||
|
||||
// Slight color variance (cyan/green/white) to keep it energetic.
|
||||
const int roll = std::rand() % 100;
|
||||
if (roll < 55) {
|
||||
p.color = SDL_Color{110, 255, 210, 255};
|
||||
} else if (roll < 90) {
|
||||
p.color = SDL_Color{120, 210, 255, 255};
|
||||
} else {
|
||||
p.color = SDL_Color{255, 255, 255, 255};
|
||||
}
|
||||
|
||||
m_particles.push_back(p);
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SpawnBurst(int count) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
SpawnParticle();
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SetRect(const SDL_FRect& rect) {
|
||||
m_rect = rect;
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SetState(SyncState state) {
|
||||
if (state != SyncState::ClearFlash) {
|
||||
m_state = state;
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::TriggerClearFlash() {
|
||||
m_state = SyncState::ClearFlash;
|
||||
m_flashTimer = FLASH_DURATION;
|
||||
|
||||
// Reward burst: strong visual feedback on cooperative clear.
|
||||
SpawnBurst(56);
|
||||
}
|
||||
|
||||
void SyncLineRenderer::Update(float deltaTime) {
|
||||
m_time += deltaTime;
|
||||
m_pulseTime += deltaTime;
|
||||
|
||||
// State-driven particle spawning
|
||||
float spawnRatePerSec = 0.0f;
|
||||
int particlesPerSpawn = 1;
|
||||
switch (m_state) {
|
||||
case SyncState::LeftReady:
|
||||
case SyncState::RightReady:
|
||||
spawnRatePerSec = 24.0f; // steady
|
||||
break;
|
||||
case SyncState::Synced:
|
||||
spawnRatePerSec = 78.0f; // very heavy stream
|
||||
particlesPerSpawn = 2;
|
||||
break;
|
||||
default:
|
||||
spawnRatePerSec = 18.0f; // always-on sparkle stream
|
||||
break;
|
||||
}
|
||||
|
||||
if (spawnRatePerSec <= 0.0f) {
|
||||
m_spawnAcc = 0.0f;
|
||||
} else {
|
||||
m_spawnAcc += deltaTime * spawnRatePerSec;
|
||||
while (m_spawnAcc >= 1.0f) {
|
||||
m_spawnAcc -= 1.0f;
|
||||
for (int i = 0; i < particlesPerSpawn; ++i) {
|
||||
SpawnParticle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update particles
|
||||
for (auto& p : m_particles) {
|
||||
p.x += p.vx * deltaTime;
|
||||
p.y -= p.vy * deltaTime;
|
||||
// Slow drift & fade.
|
||||
p.vx *= (1.0f - 0.35f * deltaTime);
|
||||
p.alpha -= 115.0f * deltaTime;
|
||||
}
|
||||
std::erase_if(m_particles, [&](const SyncParticle& p) {
|
||||
// Cull when out of view or too far from the beam.
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||
const float maxDx = 18.0f;
|
||||
return (p.y < (m_rect.y - 16.0f)) || p.alpha <= 0.0f || std::fabs(p.x - centerX) > maxDx;
|
||||
});
|
||||
|
||||
if (m_state == SyncState::ClearFlash) {
|
||||
m_flashTimer -= deltaTime;
|
||||
if (m_flashTimer <= 0.0f) {
|
||||
m_state = SyncState::Idle;
|
||||
m_flashTimer = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Color SyncLineRenderer::GetBaseColor() const {
|
||||
switch (m_state) {
|
||||
case SyncState::LeftReady:
|
||||
case SyncState::RightReady:
|
||||
return SDL_Color{255, 220, 100, 235};
|
||||
|
||||
case SyncState::Synced:
|
||||
return SDL_Color{100, 255, 120, 240};
|
||||
|
||||
case SyncState::ClearFlash:
|
||||
return SDL_Color{255, 255, 255, 255};
|
||||
|
||||
default:
|
||||
return SDL_Color{80, 180, 255, 235};
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::Render(SDL_Renderer* renderer) {
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We render the conduit with lots of translucent layers. Using additive blending
|
||||
// for glow/pulse makes it read like a blurred beam without shaders.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
const float wobbleX = syncWobbleX(m_time);
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + wobbleX;
|
||||
const float h = m_rect.h;
|
||||
const float hotspotH = std::clamp(h * 0.12f, 18.0f, 44.0f);
|
||||
|
||||
// Flash factor (0..1)
|
||||
const float flashT = (m_state == SyncState::ClearFlash && FLASH_DURATION > 0.0f)
|
||||
? std::clamp(m_flashTimer / FLASH_DURATION, 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
|
||||
SDL_Color color = GetBaseColor();
|
||||
|
||||
// Synced pulse drives aura + core intensity.
|
||||
float pulse01 = 0.0f;
|
||||
if (m_state == SyncState::Synced) {
|
||||
pulse01 = 0.5f + 0.5f * std::sinf(m_time * 6.0f);
|
||||
}
|
||||
|
||||
// 1) Outer aura layers (bloom-like using rectangles)
|
||||
auto drawGlow = [&](float extraW, Uint8 a, SDL_Color c) {
|
||||
SDL_FRect fr{
|
||||
centerX - (m_rect.w + extraW) * 0.5f,
|
||||
m_rect.y,
|
||||
m_rect.w + extraW,
|
||||
m_rect.h
|
||||
};
|
||||
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a);
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
};
|
||||
|
||||
SDL_Color aura = color;
|
||||
// Slightly bias aura towards cyan so it reads “energy conduit”.
|
||||
aura.r = static_cast<Uint8>(std::min(255, static_cast<int>(aura.r) + 10));
|
||||
aura.g = static_cast<Uint8>(std::min(255, static_cast<int>(aura.g) + 10));
|
||||
aura.b = static_cast<Uint8>(std::min(255, static_cast<int>(aura.b) + 35));
|
||||
|
||||
const float auraBoost = (m_state == SyncState::Synced) ? (0.70f + 0.80f * pulse01) : 0.70f;
|
||||
const float flashBoost = 1.0f + flashT * 1.45f;
|
||||
|
||||
SDL_BlendMode oldBlend = SDL_BLENDMODE_BLEND;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
|
||||
SDL_Color auraOuter = aura;
|
||||
auraOuter.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.r) + 10));
|
||||
auraOuter.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.g) + 5));
|
||||
auraOuter.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.b) + 55));
|
||||
|
||||
SDL_Color auraInner = aura;
|
||||
auraInner.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.r) + 40));
|
||||
auraInner.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.g) + 40));
|
||||
auraInner.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.b) + 70));
|
||||
|
||||
// Wider + softer outer halo, then tighter inner glow.
|
||||
drawGlow(62.0f, static_cast<Uint8>(std::clamp(12.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(44.0f, static_cast<Uint8>(std::clamp(20.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(30.0f, static_cast<Uint8>(std::clamp(34.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(18.0f, static_cast<Uint8>(std::clamp(54.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||
drawGlow(10.0f, static_cast<Uint8>(std::clamp(78.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||
|
||||
// 2) Hotspots near top/bottom (adds that “powered endpoints” vibe)
|
||||
SDL_Color hot = auraInner;
|
||||
hot.r = static_cast<Uint8>(std::min(255, static_cast<int>(hot.r) + 35));
|
||||
hot.g = static_cast<Uint8>(std::min(255, static_cast<int>(hot.g) + 35));
|
||||
hot.b = static_cast<Uint8>(std::min(255, static_cast<int>(hot.b) + 35));
|
||||
{
|
||||
const float hotW1 = 34.0f;
|
||||
const float hotW2 = 18.0f;
|
||||
SDL_FRect topHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y, m_rect.w + hotW1, hotspotH };
|
||||
SDL_FRect botHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y + m_rect.h - hotspotH, m_rect.w + hotW1, hotspotH };
|
||||
SDL_FRect topHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + hotspotH * 0.12f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||
SDL_FRect botHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + m_rect.h - hotspotH * 0.90f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||
|
||||
Uint8 ha1 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 85.0f : 55.0f) * flashBoost, 0.0f, 255.0f));
|
||||
Uint8 ha2 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 130.0f : 90.0f) * flashBoost, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, hot.r, hot.g, hot.b, ha1);
|
||||
SDL_RenderFillRect(renderer, &topHot1);
|
||||
SDL_RenderFillRect(renderer, &botHot1);
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ha2);
|
||||
SDL_RenderFillRect(renderer, &topHot2);
|
||||
SDL_RenderFillRect(renderer, &botHot2);
|
||||
}
|
||||
|
||||
// 3) Synced pulse wave (a travelling “breath” around the beam)
|
||||
if (m_state == SyncState::Synced) {
|
||||
float wave = std::fmod(m_pulseTime * 2.4f, 1.0f);
|
||||
float width = 10.0f + wave * 26.0f;
|
||||
Uint8 alpha = static_cast<Uint8>(std::clamp(150.0f * (1.0f - wave) * flashBoost, 0.0f, 255.0f));
|
||||
|
||||
SDL_FRect waveRect{
|
||||
centerX - (m_rect.w + width) * 0.5f,
|
||||
m_rect.y,
|
||||
m_rect.w + width,
|
||||
m_rect.h
|
||||
};
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, 140, 255, 220, alpha);
|
||||
SDL_RenderFillRect(renderer, &waveRect);
|
||||
}
|
||||
|
||||
// 4) Shimmer bands (stylish motion inside the conduit)
|
||||
{
|
||||
const int bands = 7;
|
||||
const float speed = (m_state == SyncState::Synced) ? 160.0f : 95.0f;
|
||||
const float bandW = m_rect.w + 12.0f;
|
||||
for (int i = 0; i < bands; ++i) {
|
||||
const float phase = (static_cast<float>(i) / static_cast<float>(bands));
|
||||
const float y = m_rect.y + std::fmod(m_time * speed + phase * h, h);
|
||||
const float fade = 0.35f + 0.65f * std::sinf((m_time * 2.1f) + phase * 6.28318f);
|
||||
const float bandH = 2.0f + (phase * 2.0f);
|
||||
Uint8 a = static_cast<Uint8>(std::clamp((26.0f + 36.0f * pulse01) * std::fabs(fade) * flashBoost, 0.0f, 255.0f));
|
||||
SDL_FRect fr{ centerX - bandW * 0.5f, y, bandW, bandH };
|
||||
SDL_SetRenderDrawColor(renderer, 200, 255, 255, a);
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Core beam (thin bright core + thicker body with horizontal gradient)
|
||||
Uint8 bodyA = color.a;
|
||||
if (m_state == SyncState::Synced) {
|
||||
bodyA = static_cast<Uint8>(std::clamp(175.0f + pulse01 * 75.0f, 0.0f, 255.0f));
|
||||
}
|
||||
// Keep the center more translucent; let glow carry intensity.
|
||||
bodyA = static_cast<Uint8>(std::clamp(bodyA * (0.72f + flashT * 0.35f), 0.0f, 255.0f));
|
||||
|
||||
// Render a smooth-looking body by stacking a few vertical strips.
|
||||
// This approximates a gradient (bright center -> soft edges) without shaders.
|
||||
{
|
||||
// Allow thinner beam while keeping gradient readable.
|
||||
const float bodyW = std::max(4.0f, m_rect.w);
|
||||
const float x0 = centerX - bodyW * 0.5f;
|
||||
|
||||
SDL_FRect left{ x0, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||
SDL_FRect mid{ x0 + bodyW * 0.34f, m_rect.y, bodyW * 0.32f, m_rect.h };
|
||||
SDL_FRect right{ x0 + bodyW * 0.66f, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, static_cast<Uint8>(std::clamp(bodyA * 0.60f, 0.0f, 255.0f)));
|
||||
SDL_RenderFillRect(renderer, &left);
|
||||
SDL_RenderFillRect(renderer, &right);
|
||||
|
||||
SDL_SetRenderDrawColor(renderer,
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.r) + 35)),
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.g) + 35)),
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.b) + 55)),
|
||||
static_cast<Uint8>(std::clamp(bodyA * 0.88f, 0.0f, 255.0f)));
|
||||
SDL_RenderFillRect(renderer, &mid);
|
||||
}
|
||||
|
||||
SDL_FRect coreRect{ centerX - 1.1f, m_rect.y, 2.2f, m_rect.h };
|
||||
Uint8 coreA = static_cast<Uint8>(std::clamp(210.0f + pulse01 * 70.0f + flashT * 95.0f, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, coreA);
|
||||
SDL_RenderFillRect(renderer, &coreRect);
|
||||
|
||||
// Switch back to normal alpha blend for particles so they stay readable.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
// 6) Energy particles (sparks/streaks traveling upward)
|
||||
for (const auto& p : m_particles) {
|
||||
Uint8 a = static_cast<Uint8>(std::clamp(p.alpha, 0.0f, 255.0f));
|
||||
|
||||
// Add a tiny sinusoidal sway so the stream feels alive.
|
||||
const float sway = std::sinf((p.y * 0.045f) + (m_time * 6.2f)) * 0.9f;
|
||||
SDL_FRect spark{ (p.x + sway) - (p.w * 0.5f), p.y, p.w, p.h };
|
||||
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, a);
|
||||
SDL_RenderFillRect(renderer, &spark);
|
||||
|
||||
// A little aura around each spark helps it read at speed.
|
||||
if (a > 40) {
|
||||
SDL_FRect sparkGlow{ spark.x - 1.0f, spark.y - 1.0f, spark.w + 2.0f, spark.h + 2.0f };
|
||||
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, static_cast<Uint8>(a * 0.35f));
|
||||
SDL_RenderFillRect(renderer, &sparkGlow);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Flash/glow overlay (adds “clear burst” punch)
|
||||
if (m_state == SyncState::ClearFlash) {
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
|
||||
const float extra = 74.0f;
|
||||
SDL_FRect glow{ centerX - (m_rect.w + extra) * 0.5f, m_rect.y, m_rect.w + extra, m_rect.h };
|
||||
Uint8 ga = static_cast<Uint8>(std::clamp(90.0f + 140.0f * flashT, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ga);
|
||||
SDL_RenderFillRect(renderer, &glow);
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
|
||||
// Restore whatever blend mode the caller had.
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
enum class SyncState {
|
||||
Idle,
|
||||
LeftReady,
|
||||
RightReady,
|
||||
Synced,
|
||||
ClearFlash
|
||||
};
|
||||
|
||||
class SyncLineRenderer {
|
||||
public:
|
||||
SyncLineRenderer();
|
||||
|
||||
void SetRect(const SDL_FRect& rect);
|
||||
void SetState(SyncState state);
|
||||
void TriggerClearFlash();
|
||||
|
||||
void Update(float deltaTime);
|
||||
void Render(SDL_Renderer* renderer);
|
||||
|
||||
private:
|
||||
struct SyncParticle {
|
||||
float x;
|
||||
float y;
|
||||
float vx;
|
||||
float vy;
|
||||
float w;
|
||||
float h;
|
||||
float alpha;
|
||||
SDL_Color color;
|
||||
};
|
||||
|
||||
SDL_FRect m_rect{};
|
||||
SyncState m_state;
|
||||
|
||||
float m_flashTimer;
|
||||
float m_time;
|
||||
|
||||
float m_pulseTime{0.0f};
|
||||
float m_spawnAcc{0.0f};
|
||||
std::vector<SyncParticle> m_particles;
|
||||
|
||||
static constexpr float FLASH_DURATION = 0.15f;
|
||||
static constexpr size_t MAX_PARTICLES = 240;
|
||||
|
||||
void SpawnParticle();
|
||||
void SpawnBurst(int count);
|
||||
|
||||
SDL_Color GetBaseColor() const;
|
||||
};
|
||||
@ -39,10 +39,34 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f
|
||||
float x = cx - w * 0.5f;
|
||||
float y = cy - h * 0.5f;
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
// In "textOnly" mode we don't draw a full button body (the art may be in the background image),
|
||||
// but we still add a subtle highlight so hover/selection feels intentional.
|
||||
if (textOnly && (isHovered || isSelected)) {
|
||||
Uint8 outlineA = isSelected ? 170 : 110;
|
||||
Uint8 fillA = isSelected ? 60 : 32;
|
||||
|
||||
SDL_Color hl = borderColor;
|
||||
hl.a = outlineA;
|
||||
SDL_SetRenderDrawColor(renderer, hl.r, hl.g, hl.b, hl.a);
|
||||
SDL_FRect o1{x - 3.0f, y - 3.0f, w + 6.0f, h + 6.0f};
|
||||
SDL_RenderRect(renderer, &o1);
|
||||
SDL_FRect o2{x - 6.0f, y - 6.0f, w + 12.0f, h + 12.0f};
|
||||
SDL_SetRenderDrawColor(renderer, hl.r, hl.g, hl.b, static_cast<Uint8>(std::max(0, (int)hl.a - 60)));
|
||||
SDL_RenderRect(renderer, &o2);
|
||||
|
||||
SDL_Color fill = bgColor;
|
||||
fill.a = fillA;
|
||||
SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a);
|
||||
SDL_FRect f{x, y, w, h};
|
||||
SDL_RenderFillRect(renderer, &f);
|
||||
}
|
||||
|
||||
if (!textOnly) {
|
||||
// Adjust colors based on state
|
||||
if (isSelected) {
|
||||
bgColor = {160, 190, 255, 255};
|
||||
// Keep caller-provided colors; just add a stronger glow.
|
||||
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 110);
|
||||
SDL_FRect glow{x - 10, y - 10, w + 20, h + 20};
|
||||
SDL_RenderFillRect(renderer, &glow);
|
||||
@ -54,7 +78,6 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f
|
||||
}
|
||||
|
||||
// Neon glow aura around the button to increase visibility (subtle)
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
for (int gi = 0; gi < 3; ++gi) {
|
||||
float grow = 6.0f + gi * 3.0f;
|
||||
Uint8 glowA = static_cast<Uint8>(std::max(0, (int)borderColor.a / (3 - gi)));
|
||||
@ -89,30 +112,42 @@ void UIRenderer::drawButton(SDL_Renderer* renderer, FontAtlas* font, float cx, f
|
||||
float iconX = cx - scaledW * 0.5f;
|
||||
float iconY = cy - scaledH * 0.5f;
|
||||
|
||||
// Apply yellow tint when selected
|
||||
SDL_FRect iconRect{iconX, iconY, scaledW, scaledH};
|
||||
|
||||
// Soft icon shadow for readability over busy backgrounds
|
||||
SDL_SetTextureBlendMode(icon, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetTextureColorMod(icon, 0, 0, 0);
|
||||
SDL_SetTextureAlphaMod(icon, 150);
|
||||
SDL_FRect shadowRect{iconX + 2.0f, iconY + 2.0f, scaledW, scaledH};
|
||||
SDL_RenderTexture(renderer, icon, nullptr, &shadowRect);
|
||||
|
||||
// Main icon (yellow tint when selected)
|
||||
if (isSelected) {
|
||||
SDL_SetTextureColorMod(icon, 255, 220, 0);
|
||||
} else {
|
||||
SDL_SetTextureColorMod(icon, 255, 255, 255);
|
||||
}
|
||||
|
||||
SDL_FRect iconRect{iconX, iconY, scaledW, scaledH};
|
||||
SDL_SetTextureAlphaMod(icon, 255);
|
||||
SDL_RenderTexture(renderer, icon, nullptr, &iconRect);
|
||||
|
||||
// Reset color mod
|
||||
// Reset
|
||||
SDL_SetTextureColorMod(icon, 255, 255, 255);
|
||||
SDL_SetTextureAlphaMod(icon, 255);
|
||||
} else if (font) {
|
||||
// Draw text (smaller scale for tighter buttons)
|
||||
// Draw text with scale based on button height.
|
||||
float textScale = 1.2f;
|
||||
if (h <= 40.0f) {
|
||||
textScale = 0.90f;
|
||||
} else if (h <= 54.0f) {
|
||||
textScale = 1.00f;
|
||||
} else if (h <= 70.0f) {
|
||||
textScale = 1.10f;
|
||||
}
|
||||
int textW = 0, textH = 0;
|
||||
font->measure(label, textScale, textW, textH);
|
||||
float tx = x + (w - static_cast<float>(textW)) * 0.5f;
|
||||
// Adjust vertical position for better alignment with background buttons
|
||||
// Vertically center text precisely within the button
|
||||
// Vertically center text precisely within the button, then nudge down slightly
|
||||
// to improve optical balance relative to icons and button art.
|
||||
const float textNudge = 3.0f; // tweak this value to move labels up/down
|
||||
float ty = y + (h - static_cast<float>(textH)) * 0.5f + textNudge;
|
||||
// Vertically center text within the button.
|
||||
float ty = y + (h - static_cast<float>(textH)) * 0.5f;
|
||||
|
||||
// Choose text color based on selection state
|
||||
SDL_Color textColor = {255, 255, 255, 255}; // Default white
|
||||
@ -197,6 +232,6 @@ void UIRenderer::drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, floa
|
||||
|
||||
// Instructions
|
||||
font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
|
||||
}
|
||||
|
||||
@ -34,11 +34,11 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
||||
if (!renderer) return;
|
||||
|
||||
const std::array<ShortcutEntry, 5> generalShortcuts{{
|
||||
{"H", "Toggle this help overlay"},
|
||||
{"F1", "Toggle this help overlay"},
|
||||
{"ESC", "Back / cancel current popup"},
|
||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||
{"M", "Mute or unmute music"},
|
||||
{"S", "Toggle sound effects"}
|
||||
{"K", "Toggle sound effects"}
|
||||
}};
|
||||
|
||||
const std::array<ShortcutEntry, 2> menuShortcuts{{
|
||||
@ -46,11 +46,12 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
||||
{"ENTER / SPACE", "Activate highlighted action"}
|
||||
}};
|
||||
|
||||
const std::array<ShortcutEntry, 7> gameplayShortcuts{{
|
||||
const std::array<ShortcutEntry, 8> gameplayShortcuts{{
|
||||
{"LEFT / RIGHT", "Move active piece"},
|
||||
{"DOWN", "Soft drop (faster fall)"},
|
||||
{"SPACE", "Hard drop / instant lock"},
|
||||
{"UP", "Rotate clockwise"},
|
||||
{"H", "Hold / swap current piece"},
|
||||
{"X", "Toggle rotation direction used by UP"},
|
||||
{"P", "Pause or resume"},
|
||||
{"ESC", "Open exit confirmation"}
|
||||
@ -134,7 +135,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
||||
SDL_SetRenderDrawColor(renderer, 90, 110, 170, 255);
|
||||
SDL_RenderRect(renderer, &footerRect);
|
||||
|
||||
const char* closeLabel = "PRESS H OR ESC TO CLOSE";
|
||||
const char* closeLabel = "PRESS F1 OR ESC TO CLOSE";
|
||||
float closeScale = fitScale(font, closeLabel, 1.0f, footerRect.w - footerPadding * 2.0f);
|
||||
int closeW = 0, closeH = 0;
|
||||
font.measure(closeLabel, closeScale, closeW, closeH);
|
||||
|
||||
59
src/logic/Board.cpp
Normal file
@ -0,0 +1,59 @@
|
||||
#include "Board.h"
|
||||
#include <algorithm>
|
||||
|
||||
namespace logic {
|
||||
|
||||
Board::Board()
|
||||
: grid_(Width * Height, Cell::Empty)
|
||||
{
|
||||
}
|
||||
|
||||
void Board::clear()
|
||||
{
|
||||
std::fill(grid_.begin(), grid_.end(), Cell::Empty);
|
||||
}
|
||||
|
||||
bool Board::inBounds(int x, int y) const
|
||||
{
|
||||
return x >= 0 && x < Width && y >= 0 && y < Height;
|
||||
}
|
||||
|
||||
Board::Cell Board::at(int x, int y) const
|
||||
{
|
||||
if (!inBounds(x, y)) return Cell::Empty;
|
||||
return grid_[y * Width + x];
|
||||
}
|
||||
|
||||
void Board::set(int x, int y, Cell c)
|
||||
{
|
||||
if (!inBounds(x, y)) return;
|
||||
grid_[y * Width + x] = c;
|
||||
}
|
||||
|
||||
int Board::clearFullLines()
|
||||
{
|
||||
int cleared = 0;
|
||||
// scan from bottom to top
|
||||
for (int y = Height - 1; y >= 0; --y) {
|
||||
bool full = true;
|
||||
for (int x = 0; x < Width; ++x) {
|
||||
if (at(x, y) == Cell::Empty) { full = false; break; }
|
||||
}
|
||||
if (full) {
|
||||
// remove row y: move all rows above down by one
|
||||
for (int yy = y; yy > 0; --yy) {
|
||||
for (int x = 0; x < Width; ++x) {
|
||||
grid_[yy * Width + x] = grid_[(yy - 1) * Width + x];
|
||||
}
|
||||
}
|
||||
// clear top row
|
||||
for (int x = 0; x < Width; ++x) grid_[x] = Cell::Empty;
|
||||
++cleared;
|
||||
// stay on same y to re-check the row that fell into place
|
||||
++y; // because next iteration decrements y
|
||||
}
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
} // namespace logic
|
||||
32
src/logic/Board.h
Normal file
@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
namespace logic {
|
||||
|
||||
class Board {
|
||||
public:
|
||||
static constexpr int Width = 10;
|
||||
static constexpr int Height = 20;
|
||||
|
||||
enum class Cell : uint8_t { Empty = 0, Filled = 1 };
|
||||
|
||||
Board();
|
||||
|
||||
void clear();
|
||||
|
||||
Cell at(int x, int y) const;
|
||||
void set(int x, int y, Cell c);
|
||||
bool inBounds(int x, int y) const;
|
||||
|
||||
// Remove and return number of full lines cleared. Rows above fall down.
|
||||
int clearFullLines();
|
||||
|
||||
const std::vector<Cell>& data() const { return grid_; }
|
||||
|
||||
private:
|
||||
std::vector<Cell> grid_; // row-major: y*Width + x
|
||||
};
|
||||
|
||||
} // namespace logic
|
||||
2067
src/main.cpp
21
src/network/CoopNetButtons.h
Normal file
@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace coopnet {
|
||||
// 8-bit input mask carried in NetSession::InputFrame.
|
||||
// Keep in sync across capture/apply on both peers.
|
||||
enum Buttons : uint8_t {
|
||||
MoveLeft = 1u << 0,
|
||||
MoveRight = 1u << 1,
|
||||
SoftDrop = 1u << 2,
|
||||
RotCW = 1u << 3,
|
||||
RotCCW = 1u << 4,
|
||||
HardDrop = 1u << 5,
|
||||
Hold = 1u << 6,
|
||||
};
|
||||
|
||||
inline bool has(uint8_t mask, Buttons b) {
|
||||
return (mask & static_cast<uint8_t>(b)) != 0;
|
||||
}
|
||||
}
|
||||
324
src/network/NetSession.cpp
Normal file
@ -0,0 +1,324 @@
|
||||
#include "NetSession.h"
|
||||
|
||||
#include <enet/enet.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t kChannelReliable = 0;
|
||||
|
||||
static bool netLogVerboseEnabled() {
|
||||
// Set environment variable / hint: SPACETRIS_NET_LOG=1
|
||||
const char* v = SDL_GetHint("SPACETRIS_NET_LOG");
|
||||
return v && v[0] == '1';
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
static void append(std::vector<uint8_t>& out, const T& value) {
|
||||
const uint8_t* p = reinterpret_cast<const uint8_t*>(&value);
|
||||
out.insert(out.end(), p, p + sizeof(T));
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
static bool read(const uint8_t* data, size_t size, size_t& off, T& out) {
|
||||
if (off + sizeof(T) > size) return false;
|
||||
std::memcpy(&out, data + off, sizeof(T));
|
||||
off += sizeof(T);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
NetSession::NetSession() = default;
|
||||
|
||||
NetSession::~NetSession() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
bool NetSession::ensureEnetInitialized() {
|
||||
static bool s_inited = false;
|
||||
if (s_inited) return true;
|
||||
if (enet_initialize() != 0) {
|
||||
setError("enet_initialize failed");
|
||||
m_state = ConnState::Error;
|
||||
return false;
|
||||
}
|
||||
s_inited = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetSession::setError(const std::string& msg) {
|
||||
m_lastError = msg;
|
||||
}
|
||||
|
||||
bool NetSession::host(const std::string& bindHost, uint16_t port) {
|
||||
shutdown();
|
||||
if (!ensureEnetInitialized()) return false;
|
||||
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] host(bind='%s', port=%u)", bindHost.c_str(), (unsigned)port);
|
||||
|
||||
ENetAddress address{};
|
||||
address.host = ENET_HOST_ANY;
|
||||
address.port = port;
|
||||
|
||||
if (!bindHost.empty() && bindHost != "0.0.0.0") {
|
||||
if (enet_address_set_host(&address, bindHost.c_str()) != 0) {
|
||||
setError("enet_address_set_host (bind) failed");
|
||||
m_state = ConnState::Error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 1 peer, 2 channels (reserve extra)
|
||||
m_host = enet_host_create(&address, 1, 2, 0, 0);
|
||||
if (!m_host) {
|
||||
setError("enet_host_create (host) failed");
|
||||
m_state = ConnState::Error;
|
||||
return false;
|
||||
}
|
||||
|
||||
m_mode = Mode::Host;
|
||||
m_state = ConnState::Connecting;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NetSession::join(const std::string& hostNameOrIp, uint16_t port) {
|
||||
shutdown();
|
||||
if (!ensureEnetInitialized()) return false;
|
||||
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] join(remote='%s', port=%u)", hostNameOrIp.c_str(), (unsigned)port);
|
||||
|
||||
m_host = enet_host_create(nullptr, 1, 2, 0, 0);
|
||||
if (!m_host) {
|
||||
setError("enet_host_create (client) failed");
|
||||
m_state = ConnState::Error;
|
||||
return false;
|
||||
}
|
||||
|
||||
ENetAddress address{};
|
||||
if (enet_address_set_host(&address, hostNameOrIp.c_str()) != 0) {
|
||||
setError("enet_address_set_host failed");
|
||||
m_state = ConnState::Error;
|
||||
return false;
|
||||
}
|
||||
address.port = port;
|
||||
|
||||
m_peer = enet_host_connect(m_host, &address, 2, 0);
|
||||
if (!m_peer) {
|
||||
setError("enet_host_connect failed");
|
||||
m_state = ConnState::Error;
|
||||
return false;
|
||||
}
|
||||
|
||||
m_mode = Mode::Client;
|
||||
m_state = ConnState::Connecting;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetSession::shutdown() {
|
||||
if (m_host || m_peer) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] shutdown(mode=%d state=%d)", (int)m_mode, (int)m_state);
|
||||
}
|
||||
|
||||
m_remoteInputs.clear();
|
||||
m_remoteHashes.clear();
|
||||
m_receivedHandshake.reset();
|
||||
|
||||
m_inputsSent = 0;
|
||||
m_inputsReceived = 0;
|
||||
m_hashesSent = 0;
|
||||
m_hashesReceived = 0;
|
||||
m_handshakesSent = 0;
|
||||
m_handshakesReceived = 0;
|
||||
m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||
m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||
m_lastStatsLogMs = 0;
|
||||
|
||||
if (m_peer) {
|
||||
enet_peer_disconnect(m_peer, 0);
|
||||
m_peer = nullptr;
|
||||
}
|
||||
|
||||
if (m_host) {
|
||||
enet_host_destroy(m_host);
|
||||
m_host = nullptr;
|
||||
}
|
||||
|
||||
m_mode = Mode::None;
|
||||
m_state = ConnState::Disconnected;
|
||||
m_lastError.clear();
|
||||
}
|
||||
|
||||
void NetSession::poll(uint32_t timeoutMs) {
|
||||
if (!m_host) return;
|
||||
|
||||
ENetEvent event{};
|
||||
while (enet_host_service(m_host, &event, static_cast<enet_uint32>(timeoutMs)) > 0) {
|
||||
switch (event.type) {
|
||||
case ENET_EVENT_TYPE_CONNECT:
|
||||
m_peer = event.peer;
|
||||
m_state = ConnState::Connected;
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] connected (mode=%d)", (int)m_mode);
|
||||
break;
|
||||
case ENET_EVENT_TYPE_RECEIVE:
|
||||
if (event.packet) {
|
||||
handlePacket(event.packet->data, event.packet->dataLength);
|
||||
enet_packet_destroy(event.packet);
|
||||
}
|
||||
break;
|
||||
case ENET_EVENT_TYPE_DISCONNECT:
|
||||
m_peer = nullptr;
|
||||
m_state = ConnState::Disconnected;
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET] disconnected");
|
||||
break;
|
||||
case ENET_EVENT_TYPE_NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// After first event, do non-blocking passes.
|
||||
timeoutMs = 0;
|
||||
}
|
||||
|
||||
// Rate-limited stats log (opt-in)
|
||||
if (netLogVerboseEnabled()) {
|
||||
const uint32_t nowMs = SDL_GetTicks();
|
||||
if (m_lastStatsLogMs == 0) m_lastStatsLogMs = nowMs;
|
||||
if (nowMs - m_lastStatsLogMs >= 1000u) {
|
||||
m_lastStatsLogMs = nowMs;
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||
"[NET] stats: sent(in=%u hash=%u hs=%u) recv(in=%u hash=%u hs=%u) lastRecv(inTick=%u hashTick=%u) state=%d",
|
||||
m_inputsSent,
|
||||
m_hashesSent,
|
||||
m_handshakesSent,
|
||||
m_inputsReceived,
|
||||
m_hashesReceived,
|
||||
m_handshakesReceived,
|
||||
m_lastRecvInputTick,
|
||||
m_lastRecvHashTick,
|
||||
(int)m_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool NetSession::sendBytesReliable(const void* data, size_t size) {
|
||||
if (!m_peer) return false;
|
||||
ENetPacket* packet = enet_packet_create(data, size, ENET_PACKET_FLAG_RELIABLE);
|
||||
if (!packet) return false;
|
||||
if (enet_peer_send(m_peer, kChannelReliable, packet) != 0) {
|
||||
enet_packet_destroy(packet);
|
||||
return false;
|
||||
}
|
||||
// Let the caller decide flush cadence; but for tiny control packets, flushing is cheap.
|
||||
enet_host_flush(m_host);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NetSession::sendHandshake(const Handshake& hs) {
|
||||
if (m_mode != Mode::Host) return false;
|
||||
m_handshakesSent++;
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||
std::vector<uint8_t> buf;
|
||||
buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t));
|
||||
buf.push_back(static_cast<uint8_t>(MsgType::Handshake));
|
||||
append(buf, hs.rngSeed);
|
||||
append(buf, hs.startTick);
|
||||
append(buf, hs.startLevel);
|
||||
return sendBytesReliable(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
std::optional<NetSession::Handshake> NetSession::takeReceivedHandshake() {
|
||||
auto out = m_receivedHandshake;
|
||||
m_receivedHandshake.reset();
|
||||
return out;
|
||||
}
|
||||
|
||||
bool NetSession::sendLocalInput(uint32_t tick, uint8_t buttons) {
|
||||
m_inputsSent++;
|
||||
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||
}
|
||||
std::vector<uint8_t> buf;
|
||||
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t));
|
||||
buf.push_back(static_cast<uint8_t>(MsgType::Input));
|
||||
append(buf, tick);
|
||||
append(buf, buttons);
|
||||
return sendBytesReliable(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
std::optional<uint8_t> NetSession::getRemoteButtons(uint32_t tick) const {
|
||||
auto it = m_remoteInputs.find(tick);
|
||||
if (it == m_remoteInputs.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
bool NetSession::sendStateHash(uint32_t tick, uint64_t hash) {
|
||||
m_hashesSent++;
|
||||
if (netLogVerboseEnabled()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||
}
|
||||
std::vector<uint8_t> buf;
|
||||
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t));
|
||||
buf.push_back(static_cast<uint8_t>(MsgType::Hash));
|
||||
append(buf, tick);
|
||||
append(buf, hash);
|
||||
return sendBytesReliable(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
std::optional<uint64_t> NetSession::takeRemoteHash(uint32_t tick) {
|
||||
auto it = m_remoteHashes.find(tick);
|
||||
if (it == m_remoteHashes.end()) return std::nullopt;
|
||||
uint64_t v = it->second;
|
||||
m_remoteHashes.erase(it);
|
||||
return v;
|
||||
}
|
||||
|
||||
void NetSession::handlePacket(const uint8_t* data, size_t size) {
|
||||
if (!data || size < 1) return;
|
||||
size_t off = 0;
|
||||
uint8_t typeByte = 0;
|
||||
if (!read(data, size, off, typeByte)) return;
|
||||
|
||||
MsgType t = static_cast<MsgType>(typeByte);
|
||||
switch (t) {
|
||||
case MsgType::Handshake: {
|
||||
Handshake hs{};
|
||||
if (!read(data, size, off, hs.rngSeed)) return;
|
||||
if (!read(data, size, off, hs.startTick)) return;
|
||||
if (!read(data, size, off, hs.startLevel)) return;
|
||||
m_receivedHandshake = hs;
|
||||
m_handshakesReceived++;
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||
break;
|
||||
}
|
||||
case MsgType::Input: {
|
||||
uint32_t tick = 0;
|
||||
uint8_t buttons = 0;
|
||||
if (!read(data, size, off, tick)) return;
|
||||
if (!read(data, size, off, buttons)) return;
|
||||
m_remoteInputs[tick] = buttons;
|
||||
m_inputsReceived++;
|
||||
m_lastRecvInputTick = tick;
|
||||
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MsgType::Hash: {
|
||||
uint32_t tick = 0;
|
||||
uint64_t hash = 0;
|
||||
if (!read(data, size, off, tick)) return;
|
||||
if (!read(data, size, off, hash)) return;
|
||||
m_remoteHashes[tick] = hash;
|
||||
m_hashesReceived++;
|
||||
m_lastRecvHashTick = tick;
|
||||
if (netLogVerboseEnabled()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
118
src/network/NetSession.h
Normal file
@ -0,0 +1,118 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
struct _ENetHost;
|
||||
struct _ENetPeer;
|
||||
|
||||
// Lockstep networking session for COOPERATE (network) mode.
|
||||
//
|
||||
// Design goals:
|
||||
// - Non-blocking polling (caller drives poll from the main loop)
|
||||
// - Reliable, ordered delivery for inputs and control messages
|
||||
// - Host provides seed + start tick (handshake)
|
||||
// - Only inputs/state hashes are exchanged (no board sync)
|
||||
class NetSession {
|
||||
public:
|
||||
enum class Mode {
|
||||
None,
|
||||
Host,
|
||||
Client,
|
||||
};
|
||||
|
||||
enum class ConnState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Error,
|
||||
};
|
||||
|
||||
struct Handshake {
|
||||
uint32_t rngSeed = 0;
|
||||
uint32_t startTick = 0;
|
||||
uint8_t startLevel = 0;
|
||||
};
|
||||
|
||||
struct InputFrame {
|
||||
uint32_t tick = 0;
|
||||
uint8_t buttons = 0;
|
||||
};
|
||||
|
||||
NetSession();
|
||||
~NetSession();
|
||||
|
||||
NetSession(const NetSession&) = delete;
|
||||
NetSession& operator=(const NetSession&) = delete;
|
||||
|
||||
// If bindHost is empty or "0.0.0.0", binds to ENET_HOST_ANY.
|
||||
bool host(const std::string& bindHost, uint16_t port);
|
||||
bool join(const std::string& hostNameOrIp, uint16_t port);
|
||||
void shutdown();
|
||||
|
||||
void poll(uint32_t timeoutMs = 0);
|
||||
|
||||
Mode mode() const { return m_mode; }
|
||||
ConnState state() const { return m_state; }
|
||||
bool isConnected() const { return m_state == ConnState::Connected; }
|
||||
|
||||
// Host-only: send handshake once the peer connects.
|
||||
bool sendHandshake(const Handshake& hs);
|
||||
|
||||
// Client-only: becomes available once received from host.
|
||||
std::optional<Handshake> takeReceivedHandshake();
|
||||
|
||||
// Input exchange --------------------------------------------------------
|
||||
// Send local input for a given simulation tick.
|
||||
bool sendLocalInput(uint32_t tick, uint8_t buttons);
|
||||
|
||||
// Returns the last received remote input for a tick (if any).
|
||||
std::optional<uint8_t> getRemoteButtons(uint32_t tick) const;
|
||||
|
||||
// Hash exchange (for desync detection) ---------------------------------
|
||||
bool sendStateHash(uint32_t tick, uint64_t hash);
|
||||
std::optional<uint64_t> takeRemoteHash(uint32_t tick);
|
||||
|
||||
// Diagnostics
|
||||
std::string lastError() const { return m_lastError; }
|
||||
|
||||
private:
|
||||
enum class MsgType : uint8_t {
|
||||
Handshake = 1,
|
||||
Input = 2,
|
||||
Hash = 3,
|
||||
};
|
||||
|
||||
bool ensureEnetInitialized();
|
||||
void setError(const std::string& msg);
|
||||
|
||||
bool sendBytesReliable(const void* data, size_t size);
|
||||
void handlePacket(const uint8_t* data, size_t size);
|
||||
|
||||
Mode m_mode = Mode::None;
|
||||
ConnState m_state = ConnState::Disconnected;
|
||||
|
||||
_ENetHost* m_host = nullptr;
|
||||
_ENetPeer* m_peer = nullptr;
|
||||
|
||||
std::string m_lastError;
|
||||
|
||||
std::optional<Handshake> m_receivedHandshake;
|
||||
|
||||
std::unordered_map<uint32_t, uint8_t> m_remoteInputs;
|
||||
std::unordered_map<uint32_t, uint64_t> m_remoteHashes;
|
||||
|
||||
// Debug logging (rate-limited)
|
||||
uint32_t m_inputsSent = 0;
|
||||
uint32_t m_inputsReceived = 0;
|
||||
uint32_t m_hashesSent = 0;
|
||||
uint32_t m_hashesReceived = 0;
|
||||
uint32_t m_handshakesSent = 0;
|
||||
uint32_t m_handshakesReceived = 0;
|
||||
uint32_t m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||
uint32_t m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||
uint32_t m_lastStatsLogMs = 0;
|
||||
};
|
||||
182
src/network/supabase_client.cpp
Normal file
@ -0,0 +1,182 @@
|
||||
#include "supabase_client.h"
|
||||
#include <curl/curl.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
// Supabase constants (publishable anon key)
|
||||
const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co";
|
||||
const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA";
|
||||
|
||||
std::string buildUrl(const std::string &path) {
|
||||
std::string url = SUPABASE_URL;
|
||||
if (!url.empty() && url.back() == '/') url.pop_back();
|
||||
url += "/rest/v1/" + path;
|
||||
return url;
|
||||
}
|
||||
|
||||
size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
size_t realSize = size * nmemb;
|
||||
std::string *s = reinterpret_cast<std::string*>(userp);
|
||||
s->append(reinterpret_cast<char*>(contents), realSize);
|
||||
return realSize;
|
||||
}
|
||||
|
||||
struct CurlInit {
|
||||
CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); }
|
||||
~CurlInit() { curl_global_cleanup(); }
|
||||
};
|
||||
static CurlInit g_curl_init;
|
||||
}
|
||||
|
||||
namespace supabase {
|
||||
|
||||
static bool g_verbose = false;
|
||||
|
||||
void SetVerbose(bool enabled) {
|
||||
g_verbose = enabled;
|
||||
}
|
||||
|
||||
|
||||
void SubmitHighscoreAsync(const ScoreEntry &entry) {
|
||||
std::thread([entry]() {
|
||||
try {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return;
|
||||
|
||||
std::string url = buildUrl("highscores");
|
||||
|
||||
json j;
|
||||
j["score"] = entry.score;
|
||||
j["lines"] = entry.lines;
|
||||
j["level"] = entry.level;
|
||||
j["time_sec"] = static_cast<int>(std::lround(entry.timeSec));
|
||||
j["name"] = entry.name;
|
||||
j["game_type"] = entry.gameType;
|
||||
j["timestamp"] = static_cast<int>(std::time(nullptr));
|
||||
|
||||
std::string body = j.dump();
|
||||
struct curl_slist *headers = nullptr;
|
||||
std::string h1 = std::string("apikey: ") + SUPABASE_ANON_KEY;
|
||||
std::string h2 = std::string("Authorization: Bearer ") + SUPABASE_ANON_KEY;
|
||||
headers = curl_slist_append(headers, h1.c_str());
|
||||
headers = curl_slist_append(headers, h2.c_str());
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
std::string resp;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
|
||||
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] POST " << url << "\n";
|
||||
std::cerr << "[Supabase] Body: " << body << "\n";
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
if (res != CURLE_OK) {
|
||||
if (g_verbose) std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n";
|
||||
} else {
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] POST response code: " << http_code << " body_len=" << resp.size() << "\n";
|
||||
if (!resp.empty()) std::cerr << "[Supabase] POST response: " << resp << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
} catch (...) {
|
||||
// swallow errors
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
|
||||
std::vector<ScoreEntry> FetchHighscores(const std::string &gameType, int limit) {
|
||||
std::vector<ScoreEntry> out;
|
||||
try {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return out;
|
||||
|
||||
std::string path = "highscores";
|
||||
// Clamp limit to max 10 to keep payloads small
|
||||
int l = std::clamp(limit, 1, 10);
|
||||
std::string query;
|
||||
if (!gameType.empty()) {
|
||||
if (gameType == "challenge") {
|
||||
query = "?game_type=eq." + gameType + "&order=level.desc,time_sec.asc&limit=" + std::to_string(l);
|
||||
} else {
|
||||
query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(l);
|
||||
}
|
||||
} else {
|
||||
query = "?order=score.desc&limit=" + std::to_string(l);
|
||||
}
|
||||
|
||||
std::string url = buildUrl(path) + query;
|
||||
|
||||
struct curl_slist *headers = nullptr;
|
||||
headers = curl_slist_append(headers, ("apikey: " + SUPABASE_ANON_KEY).c_str());
|
||||
headers = curl_slist_append(headers, ("Authorization: Bearer " + SUPABASE_ANON_KEY).c_str());
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
|
||||
std::string resp;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
|
||||
|
||||
if (g_verbose) std::cerr << "[Supabase] GET " << url << "\n";
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
if (res == CURLE_OK) {
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] GET response code: " << http_code << " body_len=" << resp.size() << "\n";
|
||||
if (!resp.empty()) std::cerr << "[Supabase] GET response: " << resp << "\n";
|
||||
}
|
||||
try {
|
||||
auto j = json::parse(resp);
|
||||
if (j.is_array()) {
|
||||
for (auto &v : j) {
|
||||
ScoreEntry e{};
|
||||
if (v.contains("score")) e.score = v["score"].get<int>();
|
||||
if (v.contains("lines")) e.lines = v["lines"].get<int>();
|
||||
if (v.contains("level")) e.level = v["level"].get<int>();
|
||||
if (v.contains("time_sec")) {
|
||||
try { e.timeSec = v["time_sec"].get<double>(); } catch(...) { e.timeSec = v["time_sec"].get<int>(); }
|
||||
} else if (v.contains("timestamp")) {
|
||||
e.timeSec = v["timestamp"].get<int>();
|
||||
}
|
||||
if (v.contains("name")) e.name = v["name"].get<std::string>();
|
||||
if (v.contains("game_type")) e.gameType = v["game_type"].get<std::string>();
|
||||
out.push_back(e);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
if (g_verbose) std::cerr << "[Supabase] GET parse error" << std::endl;
|
||||
}
|
||||
} else {
|
||||
if (g_verbose) std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n";
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
} catch (...) {
|
||||
// swallow
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace supabase
|
||||