Compare commits

..

18 Commits

Author SHA1 Message Date
516aa16737 Merge branch 'feature/OptimizeGame' into develop 2025-12-25 19:50:42 +01:00
735e966608 Updated and fixed audio 2025-12-25 19:41:19 +01:00
68b35ea57b Audio update 2025-12-25 19:17:36 +01:00
938988c876 fixed 2025-12-25 18:23:19 +01:00
03bdc82dc1 Updated renderer
Added Renderer_iface.h as a clean interface.
Replaced usages of old/ambiguous SDL calls in SDLRenderer.cpp to call SDL3 APIs: SDL_RenderTexture, SDL_RenderFillRect, SDL_RenderRect, SDL_RenderLine.
Converted copy() to call SDL_RenderTexture by converting integer rects to float rects.
Updated GameRenderer.cpp to include the new clean interface.
2025-12-25 17:26:55 +01:00
17cb64c9d4 fixed game renderer 2025-12-25 14:39:56 +01:00
6ef93e4c9c fixed gitignore 2025-12-25 14:24:46 +01:00
e2dd768faf fixed gitignore 2025-12-25 14:24:04 +01:00
0b546ce25c Fixed resource loader 2025-12-25 14:23:17 +01:00
45086e58d8 Add pure game model + GTest board tests and scaffolding
Add SDL-free Board model: Board.h, Board.cpp
Add unit tests for Board using Google Test: test_board.cpp
Integrate test_board into CMake and register with CTest: update CMakeLists.txt
Add gtest to vcpkg.json so CMake can find GTest
Add high-level refactor plan: plan-spacetrisRefactor.prompt.md
Update internal TODOs to mark logic extraction complete
This scaffolds deterministic, testable game logic and CI-friendly tests without changing existing runtime behavior.
2025-12-25 10:27:35 +01:00
b1f2033880 Scaffold the pure game model
- Added a pure, SDL-free Board model implementing grid access and clearFullLines().
- Added a small standalone test at test_board.cpp (simple assert-based; not yet wired into CMake).
2025-12-25 10:15:23 +01:00
5fd3febd8e Merge tag 'v0.1.0' into develop 2025-12-25 10:03:49 +01:00
60d6a9e740 Merge branch 'release/v0.1.0'
Some checks failed
Build and Package Spacetris / build-windows (push) Has been cancelled
Build and Package Spacetris / build-linux (push) Has been cancelled
2025-12-25 10:03:42 +01:00
e1921858ed Merge branch 'feature/NetworkMultiplayerCooperate' into develop 2025-12-25 09:38:19 +01:00
14cb96345c Added intro video 2025-12-25 09:38:06 +01:00
d28feb3276 minor fixes 2025-12-23 20:24:50 +01:00
a7a3ae9055 added basic network play 2025-12-23 19:03:33 +01:00
5ec4bf926b added rules 2025-12-23 17:16:12 +01:00
54 changed files with 3559 additions and 353 deletions

168
.copilot-rules.md Normal file
View 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 (46 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

4
.gitignore vendored
View File

@ -18,6 +18,7 @@
CMakeCache.txt CMakeCache.txt
cmake_install.cmake cmake_install.cmake
Makefile Makefile
settings.ini
# vcpkg # vcpkg
/vcpkg_installed/ /vcpkg_installed/
@ -70,7 +71,4 @@ dist_package/
# Local environment files (if any) # Local environment files (if any)
.env .env
# Ignore local settings file
settings.ini
# End of .gitignore # End of .gitignore

View File

@ -28,6 +28,7 @@ find_package(SDL3_ttf CONFIG REQUIRED)
find_package(SDL3_image CONFIG REQUIRED) find_package(SDL3_image CONFIG REQUIRED)
find_package(cpr CONFIG REQUIRED) find_package(cpr CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED)
find_package(unofficial-enet CONFIG REQUIRED)
set(TETRIS_SOURCES set(TETRIS_SOURCES
src/main.cpp src/main.cpp
@ -46,6 +47,7 @@ set(TETRIS_SOURCES
src/graphics/renderers/RenderManager.cpp src/graphics/renderers/RenderManager.cpp
src/persistence/Scores.cpp src/persistence/Scores.cpp
src/network/supabase_client.cpp src/network/supabase_client.cpp
src/network/NetSession.cpp
src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield.cpp
src/graphics/effects/Starfield3D.cpp src/graphics/effects/Starfield3D.cpp
src/graphics/effects/SpaceWarp.cpp src/graphics/effects/SpaceWarp.cpp
@ -55,17 +57,22 @@ set(TETRIS_SOURCES
src/graphics/renderers/SyncLineRenderer.cpp src/graphics/renderers/SyncLineRenderer.cpp
src/graphics/renderers/UIRenderer.cpp src/graphics/renderers/UIRenderer.cpp
src/audio/Audio.cpp src/audio/Audio.cpp
src/audio/AudioManager.cpp
src/renderer/SDLRenderer.cpp
src/gameplay/effects/LineEffect.cpp src/gameplay/effects/LineEffect.cpp
src/audio/SoundEffect.cpp src/audio/SoundEffect.cpp
src/video/VideoPlayer.cpp
src/ui/MenuLayout.cpp src/ui/MenuLayout.cpp
src/ui/BottomMenu.cpp src/ui/BottomMenu.cpp
src/app/BackgroundManager.cpp src/app/BackgroundManager.cpp
src/app/Fireworks.cpp src/app/Fireworks.cpp
src/app/AssetLoader.cpp src/app/AssetLoader.cpp
src/app/TextureLoader.cpp src/app/TextureLoader.cpp
src/resources/ResourceManager.cpp
src/states/LoadingManager.cpp src/states/LoadingManager.cpp
# State implementations (new) # State implementations (new)
src/states/LoadingState.cpp src/states/LoadingState.cpp
src/states/VideoState.cpp
src/states/MenuState.cpp src/states/MenuState.cpp
src/states/OptionsState.cpp src/states/OptionsState.cpp
src/states/LevelSelectorState.cpp src/states/LevelSelectorState.cpp
@ -160,10 +167,17 @@ if(APPLE)
endif() endif()
endif() endif()
target_link_libraries(spacetris 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) if (WIN32)
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid) target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
endif() endif()
if(APPLE) if(APPLE)
# Needed for MP3 decoding via AudioToolbox on macOS # Needed for MP3 decoding via AudioToolbox on macOS
@ -190,10 +204,25 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
target_include_directories(spacetris_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() 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 # Add new src subfolders to include path so old #includes continue to work
target_include_directories(spacetris PRIVATE target_include_directories(spacetris PRIVATE
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/src/audio ${CMAKE_SOURCE_DIR}/src/audio
${CMAKE_SOURCE_DIR}/src/video
${CMAKE_SOURCE_DIR}/src/gameplay ${CMAKE_SOURCE_DIR}/src/gameplay
${CMAKE_SOURCE_DIR}/src/graphics ${CMAKE_SOURCE_DIR}/src/graphics
${CMAKE_SOURCE_DIR}/src/persistence ${CMAKE_SOURCE_DIR}/src/persistence

Binary file not shown.

View 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: **46 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.

0
scripts/check_braces.ps1 Normal file
View File

View File

View File

View File

@ -14,7 +14,7 @@ SmoothScroll=1
UpRotateClockwise=0 UpRotateClockwise=0
[Player] [Player]
Name=P2 Name=GREGOR
[Debug] [Debug]
Enabled=1 Enabled=1

View File

@ -1,6 +1,10 @@
#include "app/AssetLoader.h" #include "app/AssetLoader.h"
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <algorithm> #include <algorithm>
#include "app/TextureLoader.h"
#include "utils/ImagePathResolver.h"
#include <filesystem>
AssetLoader::AssetLoader() = default; AssetLoader::AssetLoader() = default;
@ -37,6 +41,10 @@ void AssetLoader::shutdown() {
m_renderer = nullptr; m_renderer = nullptr;
} }
void AssetLoader::setResourceManager(resources::ResourceManager* mgr) {
m_resourceManager = mgr;
}
void AssetLoader::setBasePath(const std::string& basePath) { void AssetLoader::setBasePath(const std::string& basePath) {
m_basePath = basePath; m_basePath = basePath;
} }
@ -65,24 +73,25 @@ bool AssetLoader::performStep() {
std::string fullPath = m_basePath.empty() ? path : (m_basePath + "/" + path); std::string fullPath = m_basePath.empty() ? path : (m_basePath + "/" + path);
SDL_Surface* surf = IMG_Load(fullPath.c_str()); // Diagnostic: resolve path and check file existence
if (!surf) { const std::string resolved = AssetPath::resolveImagePath(path);
std::lock_guard<std::mutex> lk(m_errorsMutex); bool exists = false;
m_errors.push_back(std::string("IMG_Load failed: ") + fullPath + " -> " + SDL_GetError()); 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 { } else {
SDL_Texture* tex = SDL_CreateTextureFromSurface(m_renderer, surf); std::lock_guard<std::mutex> lk(m_texturesMutex);
SDL_DestroySurface(surf); auto& slot = m_textures[path];
if (!tex) { if (slot && slot != tex) {
std::lock_guard<std::mutex> lk(m_errorsMutex); SDL_DestroyTexture(slot);
m_errors.push_back(std::string("CreateTexture failed: ") + fullPath);
} else {
std::lock_guard<std::mutex> lk(m_texturesMutex);
auto& slot = m_textures[path];
if (slot && slot != tex) {
SDL_DestroyTexture(slot);
}
slot = tex;
} }
slot = tex;
} }
m_loadedTasks.fetch_add(1, std::memory_order_relaxed); m_loadedTasks.fetch_add(1, std::memory_order_relaxed);
@ -104,12 +113,17 @@ void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) {
return; return;
} }
// register in local map and resource manager
std::lock_guard<std::mutex> lk(m_texturesMutex); std::lock_guard<std::mutex> lk(m_texturesMutex);
auto& slot = m_textures[path]; auto& slot = m_textures[path];
if (slot && slot != texture) { if (slot && slot != texture) {
SDL_DestroyTexture(slot); SDL_DestroyTexture(slot);
} }
slot = texture; 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 { float AssetLoader::getProgress() const {

View File

@ -6,6 +6,7 @@
#include <mutex> #include <mutex>
#include <atomic> #include <atomic>
#include <unordered_map> #include <unordered_map>
#include "../resources/ResourceManager.h"
// Lightweight AssetLoader scaffold. // Lightweight AssetLoader scaffold.
// Responsibilities: // Responsibilities:
@ -22,6 +23,7 @@ public:
void shutdown(); void shutdown();
void setBasePath(const std::string& basePath); void setBasePath(const std::string& basePath);
void setResourceManager(resources::ResourceManager* mgr);
// Queue a texture path (relative to base path) for loading. // Queue a texture path (relative to base path) for loading.
void queueTexture(const std::string& path); void queueTexture(const std::string& path);
@ -49,6 +51,7 @@ public:
private: private:
SDL_Renderer* m_renderer = nullptr; SDL_Renderer* m_renderer = nullptr;
std::string m_basePath; std::string m_basePath;
resources::ResourceManager* m_resourceManager = nullptr;
// queued paths (simple FIFO) // queued paths (simple FIFO)
std::vector<std::string> m_queue; std::vector<std::string> m_queue;

View File

@ -31,6 +31,7 @@
#include "audio/Audio.h" #include "audio/Audio.h"
#include "audio/MenuWrappers.h" #include "audio/MenuWrappers.h"
#include "audio/SoundEffect.h" #include "audio/SoundEffect.h"
#include "audio/AudioManager.h"
#include "core/Config.h" #include "core/Config.h"
#include "core/Settings.h" #include "core/Settings.h"
@ -49,6 +50,9 @@
#include "graphics/ui/Font.h" #include "graphics/ui/Font.h"
#include "graphics/ui/HelpOverlay.h" #include "graphics/ui/HelpOverlay.h"
#include "network/CoopNetButtons.h"
#include "network/NetSession.h"
#include "persistence/Scores.h" #include "persistence/Scores.h"
#include "states/LevelSelectorState.h" #include "states/LevelSelectorState.h"
@ -57,6 +61,7 @@
#include "states/MenuState.h" #include "states/MenuState.h"
#include "states/OptionsState.h" #include "states/OptionsState.h"
#include "states/PlayingState.h" #include "states/PlayingState.h"
#include "states/VideoState.h"
#include "states/State.h" #include "states/State.h"
#include "ui/BottomMenu.h" #include "ui/BottomMenu.h"
@ -64,6 +69,7 @@
#include "ui/MenuLayout.h" #include "ui/MenuLayout.h"
#include "utils/ImagePathResolver.h" #include "utils/ImagePathResolver.h"
#include "../resources/ResourceManager.h"
// ---------- Game config ---------- // ---------- Game config ----------
static constexpr int LOGICAL_W = 1200; static constexpr int LOGICAL_W = 1200;
@ -183,6 +189,7 @@ struct TetrisApp::Impl {
AssetLoader assetLoader; AssetLoader assetLoader;
std::unique_ptr<LoadingManager> loadingManager; std::unique_ptr<LoadingManager> loadingManager;
std::unique_ptr<TextureLoader> textureLoader; std::unique_ptr<TextureLoader> textureLoader;
resources::ResourceManager resourceManager;
FontAtlas pixelFont; FontAtlas pixelFont;
FontAtlas font; FontAtlas font;
@ -259,6 +266,12 @@ struct TetrisApp::Impl {
double moveTimerMs = 0.0; double moveTimerMs = 0.0;
double p1MoveTimerMs = 0.0; double p1MoveTimerMs = 0.0;
double p2MoveTimerMs = 0.0; double p2MoveTimerMs = 0.0;
// Network coop fixed-tick state (used only when ctx.coopNetEnabled is true)
double coopNetAccMs = 0.0;
uint32_t coopNetCachedTick = 0xFFFFFFFFu;
uint8_t coopNetCachedButtons = 0;
uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu;
double DAS = 170.0; double DAS = 170.0;
double ARR = 40.0; double ARR = 40.0;
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
@ -301,11 +314,21 @@ struct TetrisApp::Impl {
std::unique_ptr<StateManager> stateMgr; std::unique_ptr<StateManager> stateMgr;
StateContext ctx{}; StateContext ctx{};
std::unique_ptr<LoadingState> loadingState; std::unique_ptr<LoadingState> loadingState;
std::unique_ptr<VideoState> videoState;
std::unique_ptr<MenuState> menuState; std::unique_ptr<MenuState> menuState;
std::unique_ptr<OptionsState> optionsState; std::unique_ptr<OptionsState> optionsState;
std::unique_ptr<LevelSelectorState> levelSelectorState; std::unique_ptr<LevelSelectorState> levelSelectorState;
std::unique_ptr<PlayingState> playingState; std::unique_ptr<PlayingState> playingState;
// Startup fade-in overlay (used after intro video).
bool startupFadeActive = false;
float startupFadeAlpha = 0.0f; // 0..1 black overlay strength
double startupFadeClockMs = 0.0;
static constexpr double STARTUP_FADE_IN_MS = 650.0;
// Intro video path.
std::string introVideoPath = "assets/videos/spacetris_intro.mp4";
int init(); int init();
void runLoop(); void runLoop();
void shutdown(); void shutdown();
@ -407,6 +430,8 @@ int TetrisApp::Impl::init()
// Asset loader (creates SDL_Textures on the main thread) // Asset loader (creates SDL_Textures on the main thread)
assetLoader.init(renderer); assetLoader.init(renderer);
// Wire resource manager into loader so textures are cached and reused
assetLoader.setResourceManager(&resourceManager);
loadingManager = std::make_unique<LoadingManager>(&assetLoader); loadingManager = std::make_unique<LoadingManager>(&assetLoader);
// Legacy image loader (used only as a fallback when AssetLoader misses) // Legacy image loader (used only as a fallback when AssetLoader misses)
@ -416,6 +441,8 @@ int TetrisApp::Impl::init()
currentLoadingMutex, currentLoadingMutex,
assetLoadErrors, assetLoadErrors,
assetLoadErrorsMutex); assetLoadErrorsMutex);
// Let legacy TextureLoader access the same resource cache
textureLoader->setResourceManager(&resourceManager);
// Load scores asynchronously but keep the worker alive until shutdown // Load scores asynchronously but keep the worker alive until shutdown
scoreLoader = std::jthread([this]() { scoreLoader = std::jthread([this]() {
@ -662,7 +689,11 @@ int TetrisApp::Impl::init()
}; };
ctx.requestFadeTransition = requestStateFade; ctx.requestFadeTransition = requestStateFade;
ctx.startupFadeActive = &startupFadeActive;
ctx.startupFadeAlpha = &startupFadeAlpha;
loadingState = std::make_unique<LoadingState>(ctx); loadingState = std::make_unique<LoadingState>(ctx);
videoState = std::make_unique<VideoState>(ctx);
menuState = std::make_unique<MenuState>(ctx); menuState = std::make_unique<MenuState>(ctx);
optionsState = std::make_unique<OptionsState>(ctx); optionsState = std::make_unique<OptionsState>(ctx);
levelSelectorState = std::make_unique<LevelSelectorState>(ctx); levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
@ -672,6 +703,20 @@ int TetrisApp::Impl::init()
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); }); stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
stateMgr->registerHandler(AppState::Video, [this](const SDL_Event& e){ if (videoState) videoState->handleEvent(e); });
stateMgr->registerOnEnter(AppState::Video, [this]() {
if (!videoState) return;
const bool ok = videoState->begin(renderer, introVideoPath);
if (!ok) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Intro video unavailable; skipping to Menu");
state = AppState::Menu;
stateMgr->setState(state);
return;
}
videoState->onEnter();
});
stateMgr->registerOnExit(AppState::Video, [this](){ if (videoState) videoState->onExit(); });
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); }); stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
@ -803,17 +848,19 @@ void TetrisApp::Impl::runLoop()
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (e.key.scancode == SDL_SCANCODE_M) if (e.key.scancode == SDL_SCANCODE_M)
{ {
Audio::instance().toggleMute(); if (auto sys = AudioManager::get()) sys->toggleMute();
musicEnabled = !musicEnabled; musicEnabled = !musicEnabled;
Settings::instance().setMusicEnabled(musicEnabled); Settings::instance().setMusicEnabled(musicEnabled);
} }
if (e.key.scancode == SDL_SCANCODE_N) if (e.key.scancode == SDL_SCANCODE_N)
{ {
Audio::instance().skipToNextTrack(); if (auto sys = AudioManager::get()) {
if (!musicStarted && Audio::instance().getLoadedTrackCount() > 0) { sys->skipToNextTrack();
musicStarted = true; if (!musicStarted && sys->getLoadedTrackCount() > 0) {
musicEnabled = true; musicStarted = true;
Settings::instance().setMusicEnabled(true); musicEnabled = true;
Settings::instance().setMusicEnabled(true);
}
} }
} }
// K: Toggle sound effects (S is reserved for co-op movement) // K: Toggle sound effects (S is reserved for co-op movement)
@ -823,7 +870,7 @@ void TetrisApp::Impl::runLoop()
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
} }
const bool helpToggleKey = const bool helpToggleKey =
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu); (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu);
if (helpToggleKey) if (helpToggleKey)
{ {
showHelpOverlay = !showHelpOverlay; showHelpOverlay = !showHelpOverlay;
@ -1149,12 +1196,31 @@ void TetrisApp::Impl::runLoop()
} }
} }
// State transitions can be triggered from render/update (e.g. menu network handshake).
// Keep our cached `state` in sync every frame, not only when events occur.
state = stateMgr->getState();
Uint64 now = SDL_GetPerformanceCounter(); Uint64 now = SDL_GetPerformanceCounter();
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
lastMs = now; lastMs = now;
if (frameMs > 100.0) frameMs = 100.0; if (frameMs > 100.0) frameMs = 100.0;
gameplayBackgroundClockMs += frameMs; gameplayBackgroundClockMs += frameMs;
if (startupFadeActive) {
if (startupFadeClockMs <= 0.0) {
startupFadeClockMs = STARTUP_FADE_IN_MS;
startupFadeAlpha = 1.0f;
}
startupFadeClockMs -= frameMs;
if (startupFadeClockMs <= 0.0) {
startupFadeClockMs = 0.0;
startupFadeAlpha = 0.0f;
startupFadeActive = false;
} else {
startupFadeAlpha = float(std::clamp(startupFadeClockMs / STARTUP_FADE_IN_MS, 0.0, 1.0));
}
}
auto clearChallengeStory = [this]() { auto clearChallengeStory = [this]() {
challengeStoryText.clear(); challengeStoryText.clear();
challengeStoryLevel = 0; challengeStoryLevel = 0;
@ -1259,10 +1325,14 @@ void TetrisApp::Impl::runLoop()
game->softDropBoost(frameMs); game->softDropBoost(frameMs);
if (musicLoadingStarted && !musicLoaded) { if (musicLoadingStarted && !musicLoaded) {
currentTrackLoading = Audio::instance().getLoadedTrackCount(); if (auto sys = AudioManager::get()) {
if (Audio::instance().isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) { currentTrackLoading = sys->getLoadedTrackCount();
Audio::instance().shuffle(); if (sys->isLoadingComplete() || (totalTracks > 0 && currentTrackLoading >= totalTracks)) {
musicLoaded = true; sys->shuffle();
musicLoaded = true;
}
} else {
currentTrackLoading = 0;
} }
} }
@ -1309,6 +1379,10 @@ void TetrisApp::Impl::runLoop()
if (game->isPaused()) { if (game->isPaused()) {
// While paused, suppress all continuous input changes so pieces don't drift. // While paused, suppress all continuous input changes so pieces don't drift.
if (ctx.coopNetEnabled && ctx.coopNetSession) {
ctx.coopNetSession->poll(0);
ctx.coopNetStalled = false;
}
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false); coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false); coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
p1MoveTimerMs = 0.0; p1MoveTimerMs = 0.0;
@ -1318,6 +1392,17 @@ void TetrisApp::Impl::runLoop()
p2LeftHeld = false; p2LeftHeld = false;
p2RightHeld = false; p2RightHeld = false;
} else { } else {
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
// If we just entered network co-op, reset per-session fixed-tick bookkeeping.
if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) {
coopNetAccMs = 0.0;
coopNetCachedTick = 0xFFFFFFFFu;
coopNetCachedButtons = 0;
coopNetLastHashSentTick = 0xFFFFFFFFu;
ctx.coopNetStalled = false;
}
// Define canonical key mappings for left and right players // Define canonical key mappings for left and right players
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A; const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
const SDL_Scancode leftRightKey = SDL_SCANCODE_D; const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
@ -1327,7 +1412,194 @@ void TetrisApp::Impl::runLoop()
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT; const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN; const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
if (!coopVsAI) { if (coopNetActive) {
// Network co-op: fixed tick lockstep.
// Use a fixed dt so both peers simulate identically.
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
ctx.coopNetSession->poll(0);
// If the connection drops during gameplay, abort back to menu.
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
: std::string("NET DISCONNECTED");
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
ctx.coopNetUiStatusText = reason;
ctx.coopNetUiStatusRemainingMs = 6000.0;
ctx.coopNetEnabled = false;
ctx.coopNetStalled = false;
ctx.coopNetDesyncDetected = false;
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
// Ensure we don't remain paused due to a previous net stall/desync.
if (game) {
game->setPaused(false);
}
state = AppState::Menu;
stateMgr->setState(state);
continue;
}
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
auto buildLocalButtons = [&]() -> uint8_t {
uint8_t b = 0;
if (ctx.coopNetLocalIsLeft) {
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
if (ks[leftRightKey]) b |= coopnet::MoveRight;
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
} else {
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
if (ks[rightRightKey]) b |= coopnet::MoveRight;
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
}
b |= ctx.coopNetPendingButtons;
ctx.coopNetPendingButtons = 0;
return b;
};
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
uint8_t buttons,
bool& leftHeldPrev,
bool& rightHeldPrev,
double& timer) {
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
coopGame->setSoftDropping(side, downHeldNow);
int moveDir = 0;
if (leftHeldNow && !rightHeldNow) moveDir = -1;
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
if (moveDir != 0) {
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
coopGame->move(side, moveDir);
timer = DAS;
} else {
timer -= FIXED_DT_MS;
if (timer <= 0.0) {
coopGame->move(side, moveDir);
timer += ARR;
}
}
} else {
timer = 0.0;
}
if (coopnet::has(buttons, coopnet::RotCW)) {
coopGame->rotate(side, +1);
}
if (coopnet::has(buttons, coopnet::RotCCW)) {
coopGame->rotate(side, -1);
}
if (coopnet::has(buttons, coopnet::HardDrop)) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
coopGame->hardDrop(side);
}
if (coopnet::has(buttons, coopnet::Hold)) {
coopGame->holdCurrent(side);
}
leftHeldPrev = leftHeldNow;
rightHeldPrev = rightHeldNow;
};
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
int safetySteps = 0;
bool advancedTick = false;
ctx.coopNetStalled = false;
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
const uint32_t tick = ctx.coopNetTick;
if (coopNetCachedTick != tick) {
coopNetCachedTick = tick;
coopNetCachedButtons = buildLocalButtons();
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
roleStr,
tick);
}
}
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
if (!remoteButtonsOpt.has_value()) {
if (!ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL begin waitingForTick=%u",
roleStr,
tick);
}
ctx.coopNetStalled = true;
break; // lockstep stall
}
const uint8_t remoteButtons = remoteButtonsOpt.value();
const bool localIsLeft = ctx.coopNetLocalIsLeft;
if (localIsLeft) {
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
} else {
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
}
coopGame->tickGravity(FIXED_DT_MS);
coopGame->updateVisualEffects(FIXED_DT_MS);
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
coopNetLastHashSentTick = tick;
const uint64_t hash = coopGame->computeStateHash();
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
roleStr,
tick,
(unsigned long long)hash);
}
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
if (rh.has_value() && rh.value() != hash) {
ctx.coopNetDesyncDetected = true;
ctx.coopNetUiStatusText = "NET DESYNC";
ctx.coopNetUiStatusRemainingMs = 8000.0;
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
roleStr,
tick,
(unsigned long long)hash,
(unsigned long long)rh.value());
game->setPaused(true);
}
}
ctx.coopNetTick++;
advancedTick = true;
coopNetAccMs -= FIXED_DT_MS;
}
if (advancedTick) {
if (ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL end atTick=%u",
roleStr,
ctx.coopNetTick);
}
ctx.coopNetStalled = false;
}
} else if (!coopVsAI) {
// Standard two-player: left uses WASD, right uses arrow keys // Standard two-player: left uses WASD, right uses arrow keys
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey); handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
@ -1357,8 +1629,10 @@ void TetrisApp::Impl::runLoop()
p2RightHeld = ks[rightRightKey]; p2RightHeld = ks[rightRightKey];
} }
coopGame->tickGravity(frameMs); if (!coopNetActive) {
coopGame->updateVisualEffects(frameMs); coopGame->tickGravity(frameMs);
coopGame->updateVisualEffects(frameMs);
}
} }
if (coopGame->isGameOver()) { if (coopGame->isGameOver()) {
@ -1387,6 +1661,12 @@ void TetrisApp::Impl::runLoop()
} }
state = AppState::GameOver; state = AppState::GameOver;
stateMgr->setState(state); stateMgr->setState(state);
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = false;
} }
} else { } else {
@ -1439,21 +1719,27 @@ void TetrisApp::Impl::runLoop()
currentLoadingFile.clear(); currentLoadingFile.clear();
} }
Audio::instance().init(); if (auto sys = AudioManager::get()) {
totalTracks = 0; sys->init();
for (int i = 1; i <= 100; ++i) { totalTracks = 0;
char base[128]; for (int i = 1; i <= 100; ++i) {
std::snprintf(base, sizeof(base), "assets/music/music%03d", i); char base[128];
std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" }); std::snprintf(base, sizeof(base), "assets/music/music%03d", i);
if (path.empty()) break; std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" });
Audio::instance().addTrackAsync(path); if (path.empty()) break;
totalTracks++; sys->addTrackAsync(path);
} totalTracks++;
totalLoadingTasks.store(baseTasks + totalTracks); }
if (totalTracks > 0) { totalLoadingTasks.store(baseTasks + totalTracks);
Audio::instance().startBackgroundLoading(); if (totalTracks > 0) {
musicLoadingStarted = true; sys->startBackgroundLoading();
musicLoadingStarted = true;
} else {
musicLoaded = true;
}
} else { } else {
totalTracks = 0;
totalLoadingTasks.store(baseTasks + totalTracks);
musicLoaded = true; musicLoaded = true;
} }
@ -1518,6 +1804,8 @@ void TetrisApp::Impl::runLoop()
nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL); nextPanelTex = assetLoader.getTexture(Assets::NEXT_PANEL);
holdPanelTex = assetLoader.getTexture(Assets::HOLD_PANEL); holdPanelTex = assetLoader.getTexture(Assets::HOLD_PANEL);
// texture retrieval diagnostics removed
auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) { auto ensureTextureSize = [&](SDL_Texture* tex, int& outW, int& outH) {
if (!tex) return; if (!tex) return;
if (outW > 0 && outH > 0) return; if (outW > 0 && outH > 0) return;
@ -1587,7 +1875,15 @@ void TetrisApp::Impl::runLoop()
if (totalTasks > 0) { if (totalTasks > 0) {
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks)); loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
if (loadingProgress >= 1.0 && musicLoaded) { if (loadingProgress >= 1.0 && musicLoaded) {
state = AppState::Menu; startupFadeActive = false;
startupFadeAlpha = 0.0f;
startupFadeClockMs = 0.0;
if (std::filesystem::exists(introVideoPath)) {
state = AppState::Video;
} else {
state = AppState::Menu;
}
stateMgr->setState(state); stateMgr->setState(state);
} }
} else { } else {
@ -1596,10 +1892,20 @@ void TetrisApp::Impl::runLoop()
if (totalTracks > 0) { if (totalTracks > 0) {
musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7); musicProgress = musicLoaded ? 0.7 : std::min(0.7, (double)currentTrackLoading / totalTracks * 0.7);
} else { } else {
if (Audio::instance().isLoadingComplete()) { if (auto sys = AudioManager::get()) {
musicProgress = 0.7; if (sys->isLoadingComplete()) {
} else if (Audio::instance().getLoadedTrackCount() > 0) { musicProgress = 0.7;
musicProgress = 0.35; } else if (sys->getLoadedTrackCount() > 0) {
musicProgress = 0.35;
} else {
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
if (elapsedMs > 1500) {
musicProgress = 0.7;
musicLoaded = true;
} else {
musicProgress = 0.0;
}
}
} else { } else {
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart); Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
if (elapsedMs > 1500) { if (elapsedMs > 1500) {
@ -1615,7 +1921,15 @@ void TetrisApp::Impl::runLoop()
if (loadingProgress > 0.99) loadingProgress = 1.0; if (loadingProgress > 0.99) loadingProgress = 1.0;
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0; if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
if (loadingProgress >= 1.0 && musicLoaded) { if (loadingProgress >= 1.0 && musicLoaded) {
state = AppState::Menu; startupFadeActive = false;
startupFadeAlpha = 0.0f;
startupFadeClockMs = 0.0;
if (std::filesystem::exists(introVideoPath)) {
state = AppState::Video;
} else {
state = AppState::Menu;
}
stateMgr->setState(state); stateMgr->setState(state);
} }
} }
@ -1633,7 +1947,7 @@ void TetrisApp::Impl::runLoop()
menuTrackLoader = std::jthread([]() { menuTrackLoader = std::jthread([]() {
std::string menuTrack = AssetPath::resolveWithExtensions("assets/music/Every Block You Take", { ".mp3" }); std::string menuTrack = AssetPath::resolveWithExtensions("assets/music/Every Block You Take", { ".mp3" });
if (!menuTrack.empty()) { if (!menuTrack.empty()) {
Audio::instance().setMenuTrack(menuTrack); if (auto sys = AudioManager::get()) sys->setMenuTrack(menuTrack);
} else { } else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Menu track not found (Every Block You Take)"); SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Menu track not found (Every Block You Take)");
} }
@ -1642,9 +1956,9 @@ void TetrisApp::Impl::runLoop()
} }
if (state == AppState::Menu) { if (state == AppState::Menu) {
Audio::instance().playMenuMusic(); if (auto sys = AudioManager::get()) sys->playMenuMusic();
} else { } else {
Audio::instance().playGameMusic(); if (auto sys = AudioManager::get()) sys->playGameMusic();
} }
musicStarted = true; musicStarted = true;
} }
@ -1653,9 +1967,9 @@ void TetrisApp::Impl::runLoop()
static AppState previousState = AppState::Loading; static AppState previousState = AppState::Loading;
if (state != previousState && musicStarted) { if (state != previousState && musicStarted) {
if (state == AppState::Menu && previousState == AppState::Playing) { if (state == AppState::Menu && previousState == AppState::Playing) {
Audio::instance().playMenuMusic(); if (auto sys = AudioManager::get()) sys->playMenuMusic();
} else if (state == AppState::Playing && previousState == AppState::Menu) { } else if (state == AppState::Playing && previousState == AppState::Menu) {
Audio::instance().playGameMusic(); if (auto sys = AudioManager::get()) sys->playGameMusic();
} }
} }
previousState = state; previousState = state;
@ -1682,6 +1996,9 @@ void TetrisApp::Impl::runLoop()
case AppState::Loading: case AppState::Loading:
loadingState->update(frameMs); loadingState->update(frameMs);
break; break;
case AppState::Video:
if (videoState) videoState->update(frameMs);
break;
case AppState::Menu: case AppState::Menu:
menuState->update(frameMs); menuState->update(frameMs);
break; break;
@ -1984,6 +2301,11 @@ void TetrisApp::Impl::runLoop()
} }
} }
break; break;
case AppState::Video:
if (videoState) {
videoState->render(renderer, logicalScale, logicalVP);
}
break;
case AppState::Menu: case AppState::Menu:
if (!mainScreenTex) { if (!mainScreenTex) {
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
@ -2377,6 +2699,17 @@ void TetrisApp::Impl::runLoop()
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY); HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
} }
if (startupFadeActive && startupFadeAlpha > 0.0f) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const Uint8 a = (Uint8)std::clamp((int)std::lround(startupFadeAlpha * 255.0f), 0, 255);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &full);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}
SDL_RenderPresent(renderer); SDL_RenderPresent(renderer);
SDL_SetRenderScale(renderer, 1.f, 1.f); SDL_SetRenderScale(renderer, 1.f, 1.f);
} }
@ -2410,7 +2743,7 @@ void TetrisApp::Impl::shutdown()
} }
lineEffect.shutdown(); lineEffect.shutdown();
Audio::instance().shutdown(); if (auto sys = AudioManager::get()) sys->shutdown();
SoundEffectManager::instance().shutdown(); SoundEffectManager::instance().shutdown();
// Destroy textures before tearing down the renderer/window. // Destroy textures before tearing down the renderer/window.

View File

@ -6,6 +6,8 @@
#include <mutex> #include <mutex>
#include <sstream> #include <sstream>
#include <filesystem>
#include "utils/ImagePathResolver.h" #include "utils/ImagePathResolver.h"
TextureLoader::TextureLoader( TextureLoader::TextureLoader(
@ -45,6 +47,18 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
const std::string resolvedPath = AssetPath::resolveImagePath(path); const std::string resolvedPath = AssetPath::resolveImagePath(path);
setCurrentLoadingFile(resolvedPath.empty() ? path : resolvedPath); 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()); SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
if (!surface) { if (!surface) {
{ {
@ -54,7 +68,7 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
} }
loadedTasks_.fetch_add(1); loadedTasks_.fetch_add(1);
clearCurrentLoadingFile(); clearCurrentLoadingFile();
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError()); 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; return nullptr;
} }
@ -66,6 +80,7 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
} }
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
// surface size preserved in outW/outH; no console log
SDL_DestroySurface(surface); SDL_DestroySurface(surface);
if (!texture) { if (!texture) {
@ -80,6 +95,15 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
return nullptr; 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); loadedTasks_.fetch_add(1);
clearCurrentLoadingFile(); clearCurrentLoadingFile();

View File

@ -6,6 +6,7 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
#include "../resources/ResourceManager.h"
class TextureLoader { class TextureLoader {
public: public:
@ -16,6 +17,8 @@ public:
std::vector<std::string>& assetLoadErrors, std::vector<std::string>& assetLoadErrors,
std::mutex& assetLoadErrorsMutex); 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); SDL_Texture* loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr);
private: private:
@ -28,4 +31,6 @@ private:
void setCurrentLoadingFile(const std::string& filename); void setCurrentLoadingFile(const std::string& filename);
void clearCurrentLoadingFile(); void clearCurrentLoadingFile();
void recordAssetLoadError(const std::string& message); void recordAssetLoadError(const std::string& message);
resources::ResourceManager* resourceManager_ = nullptr;
}; };

View File

@ -118,6 +118,7 @@ static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int
outCh = static_cast<int>(clientFormat.mChannelsPerFrame); outCh = static_cast<int>(clientFormat.mChannelsPerFrame);
return !outPCM.empty(); return !outPCM.empty();
} }
#else #else
static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int& outRate, int& outCh){ static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int& outRate, int& outCh){
(void)outPCM; (void)outRate; (void)outCh; (void)path; (void)outPCM; (void)outRate; (void)outCh; (void)path;
@ -184,6 +185,8 @@ void Audio::skipToNextTrack(){
void Audio::toggleMute(){ muted=!muted; } void Audio::toggleMute(){ muted=!muted; }
void Audio::setMuted(bool m){ muted=m; } void Audio::setMuted(bool m){ muted=m; }
bool Audio::isMuted() const { return muted; }
void Audio::nextTrack(){ void Audio::nextTrack(){
if(tracks.empty()) { current = -1; return; } if(tracks.empty()) { current = -1; return; }
// Try every track once to find a decodable one // Try every track once to find a decodable one

View File

@ -32,29 +32,27 @@ public:
void setSoundVolume(float volume) override; void setSoundVolume(float volume) override;
bool isMusicPlaying() const override; bool isMusicPlaying() const override;
// Existing Audio class methods // Additional IAudioSystem methods (forwarded to concrete implementation)
bool init(); // initialize backend (MF on Windows) bool init() override;
void addTrack(const std::string& path); // decode MP3 -> PCM16 stereo 44100 void shutdown() override;
void addTrackAsync(const std::string& path); // add track for background loading void addTrack(const std::string& path) override;
void startBackgroundLoading(); // start background thread for loading void addTrackAsync(const std::string& path) override;
void waitForLoadingComplete(); // wait for all tracks to finish loading void startBackgroundLoading() override;
bool isLoadingComplete() const; // check if background loading is done bool isLoadingComplete() const override;
int getLoadedTrackCount() const; // get number of tracks loaded so far int getLoadedTrackCount() const override;
void shuffle(); // randomize order void start() override;
void start(); // begin playback void skipToNextTrack() override;
void skipToNextTrack(); // advance to the next music track void shuffle() override;
void toggleMute(); void toggleMute() override;
bool isMuted() const override;
void setMuted(bool m); 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 // Existing Audio class helper methods
void setMenuTrack(const std::string& path); void waitForLoadingComplete(); // wait for all tracks to finish loading
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();
private: private:
Audio()=default; ~Audio()=default; Audio(const Audio&)=delete; Audio& operator=(const Audio&)=delete; 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); static void SDLCALL streamCallback(void* userdata, SDL_AudioStream* stream, int additional, int total);

View 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
View 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);
};

View File

@ -2,6 +2,7 @@
#include "SoundEffect.h" #include "SoundEffect.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include "audio/Audio.h" #include "audio/Audio.h"
#include "audio/AudioManager.h"
#include <cstdio> #include <cstdio>
#include <algorithm> #include <algorithm>
#include <random> #include <random>
@ -93,7 +94,9 @@ void SimpleAudioPlayer::playSound(const std::vector<int16_t>& pcmData, int chann
return; return;
} }
// Route through shared Audio mixer so SFX always play over music // 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) { bool SoundEffect::loadWAV(const std::string& filePath) {

View File

@ -21,7 +21,11 @@ std::string Settings::getSettingsPath() {
bool Settings::load() { bool Settings::load() {
std::ifstream file(getSettingsPath()); std::ifstream file(getSettingsPath());
if (!file.is_open()) { 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; return false;
} }

View File

@ -48,7 +48,8 @@ private:
Settings& operator=(const Settings&) = delete; Settings& operator=(const Settings&) = delete;
// Settings values // 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_musicEnabled = true;
bool m_soundEnabled = true; bool m_soundEnabled = true;
bool m_debugEnabled = false; bool m_debugEnabled = false;

View File

@ -7,6 +7,7 @@
#include "../interfaces/IInputHandler.h" #include "../interfaces/IInputHandler.h"
#include <filesystem> #include <filesystem>
#include "../../audio/Audio.h" #include "../../audio/Audio.h"
#include "../../audio/AudioManager.h"
#include "../../audio/SoundEffect.h" #include "../../audio/SoundEffect.h"
#include "../../persistence/Scores.h" #include "../../persistence/Scores.h"
#include "../../states/State.h" #include "../../states/State.h"
@ -32,9 +33,19 @@
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include "../../utils/ImagePathResolver.h" #include "../../utils/ImagePathResolver.h"
#include <iostream> #include <iostream>
#include "../../video/VideoPlayer.h"
#include <cmath> #include <cmath>
#include <fstream> #include <fstream>
#include <algorithm> #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; ApplicationManager::ApplicationManager() = default;
@ -55,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); if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
app->m_starfield3D->draw(renderer.getSDLRenderer()); 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}; SDL_Rect logicalVP = {0,0,0,0};
float logicalScale = 1.0f; float logicalScale = 1.0f;
if (app->m_renderManager) { if (app->m_renderManager) {
@ -249,7 +268,7 @@ void ApplicationManager::shutdown() {
m_running = false; m_running = false;
// Stop audio systems before tearing down SDL to avoid aborts/asserts // Stop audio systems before tearing down SDL to avoid aborts/asserts
Audio::instance().shutdown(); if (auto sys = AudioManager::get()) sys->shutdown();
SoundEffectManager::instance().shutdown(); SoundEffectManager::instance().shutdown();
// Cleanup in reverse order of initialization // Cleanup in reverse order of initialization
@ -363,11 +382,11 @@ bool ApplicationManager::initializeManagers() {
// M: Toggle/mute music; start playback if unmuting and not started yet // M: Toggle/mute music; start playback if unmuting and not started yet
if (!consume && sc == SDL_SCANCODE_M) { if (!consume && sc == SDL_SCANCODE_M) {
Audio::instance().toggleMute(); if (auto sys = AudioManager::get()) sys->toggleMute();
m_musicEnabled = !m_musicEnabled; m_musicEnabled = !m_musicEnabled;
if (m_musicEnabled && !m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) { if (m_musicEnabled && !m_musicStarted && AudioManager::get() && AudioManager::get()->getLoadedTrackCount() > 0) {
Audio::instance().shuffle(); AudioManager::get()->shuffle();
Audio::instance().start(); AudioManager::get()->start();
m_musicStarted = true; m_musicStarted = true;
} }
consume = true; consume = true;
@ -375,11 +394,7 @@ bool ApplicationManager::initializeManagers() {
// N: Skip to next song in the playlist (or restart menu track) // N: Skip to next song in the playlist (or restart menu track)
if (!consume && sc == SDL_SCANCODE_N) { if (!consume && sc == SDL_SCANCODE_N) {
Audio::instance().skipToNextTrack(); if (auto sys = AudioManager::get()) { sys->skipToNextTrack(); if (!m_musicStarted && sys->getLoadedTrackCount() > 0) { m_musicStarted = true; m_musicEnabled = true; } }
if (!m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
m_musicStarted = true;
m_musicEnabled = true;
}
consume = true; consume = true;
} }
@ -497,13 +512,13 @@ void ApplicationManager::registerServices() {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IInputHandler service"); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IInputHandler service");
} }
// Register Audio system singleton // Register Audio system singleton (via AudioManager)
auto& audioInstance = Audio::instance(); IAudioSystem* audioInstance = AudioManager::get();
auto audioPtr = std::shared_ptr<Audio>(&audioInstance, [](Audio*) { if (audioInstance) {
// Custom deleter that does nothing since Audio is a singleton std::shared_ptr<IAudioSystem> audioPtr(audioInstance, [](IAudioSystem*){});
}); m_serviceContainer.registerSingleton<IAudioSystem>(audioPtr);
m_serviceContainer.registerSingleton<IAudioSystem>(audioPtr); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IAudioSystem service");
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IAudioSystem service"); }
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Service registration completed successfully"); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Service registration completed successfully");
} }
@ -600,7 +615,7 @@ bool ApplicationManager::initializeGame() {
// as lambdas that reference members here. // as lambdas that reference members here.
// Start background music loading similar to main.cpp: Audio init + file discovery // 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 // Discover available tracks (up to 100) and queue for background loading
m_totalTracks = 0; m_totalTracks = 0;
std::vector<std::string> trackPaths; std::vector<std::string> trackPaths;
@ -616,15 +631,15 @@ bool ApplicationManager::initializeGame() {
} }
m_totalTracks = static_cast<int>(trackPaths.size()); m_totalTracks = static_cast<int>(trackPaths.size());
for (const auto& path : trackPaths) { for (const auto& path : trackPaths) {
Audio::instance().addTrackAsync(path); if (auto sys = AudioManager::get()) sys->addTrackAsync(path);
} }
if (m_totalTracks > 0) { 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. // 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. // Do not mark as started yet; we'll flip the flag once a track is actually loaded.
if (m_musicEnabled) { if (m_musicEnabled) {
Audio::instance().shuffle(); if (auto sys = AudioManager::get()) { sys->shuffle(); sys->start(); }
Audio::instance().start(); m_musicStarted = true;
} }
m_currentTrackLoading = 1; // mark started m_currentTrackLoading = 1; // mark started
} }
@ -780,17 +795,44 @@ void ApplicationManager::setupStateHandlers() {
m_starfield3D->update(deltaTime / 1000.0f); 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()) { 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 // Update texture pointers now that assets are loaded
m_stateContext.backgroundTex = m_assetManager->getTexture("background"); m_stateContext.backgroundTex = m_assetManager->getTexture("background");
m_stateContext.blocksTex = m_assetManager->getTexture("blocks"); m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
bool ok = m_stateManager->setState(AppState::Menu); // If an intro video exists and hasn't been started, attempt to play it in-process
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0); std::filesystem::path introPath = m_introPath;
traceFile("- to Menu returned"); 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);
}
} }
}); });
@ -896,15 +938,15 @@ void ApplicationManager::setupStateHandlers() {
// Start music as soon as at least one track has decoded (dont wait for all) // Start music as soon as at least one track has decoded (dont wait for all)
// 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 (m_musicEnabled && !m_musicStarted) {
if (Audio::instance().getLoadedTrackCount() > 0) { if (auto sys = AudioManager::get()) {
Audio::instance().shuffle(); if (sys->getLoadedTrackCount() > 0) { sys->shuffle(); sys->start(); m_musicStarted = true; }
Audio::instance().start();
m_musicStarted = true;
} }
} }
// Track completion status for UI // Track completion status for UI
if (!m_musicLoaded && Audio::instance().isLoadingComplete()) { if (!m_musicLoaded) {
m_musicLoaded = true; if (auto sys = AudioManager::get()) {
if (sys->isLoadingComplete()) m_musicLoaded = true;
}
} }
}); });

View File

@ -153,6 +153,11 @@ private:
float m_logoAnimCounter = 0.0f; float m_logoAnimCounter = 0.0f;
bool m_helpOverlayPausedGame = false; 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 // Gameplay background (per-level) with fade, mirroring main.cpp behavior
SDL_Texture* m_levelBackgroundTex = nullptr; SDL_Texture* m_levelBackgroundTex = nullptr;
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions

View File

@ -1,12 +1,15 @@
#include "AssetManager.h" #include "AssetManager.h"
#include "../../graphics/ui/Font.h" #include "../../graphics/ui/Font.h"
#include "../../audio/Audio.h" #include "../../audio/Audio.h"
#include "../../audio/AudioManager.h"
#include "../../audio/SoundEffect.h" #include "../../audio/SoundEffect.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include <filesystem> #include <filesystem>
#include "../../utils/ImagePathResolver.h" #include "../../utils/ImagePathResolver.h"
#include "../../core/Config.h"
#include "../../resources/AssetPaths.h"
AssetManager::AssetManager() AssetManager::AssetManager()
: m_renderer(nullptr) : m_renderer(nullptr)
@ -38,7 +41,7 @@ bool AssetManager::initialize(SDL_Renderer* renderer) {
m_renderer = renderer; m_renderer = renderer;
// Get references to singleton systems // Get references to singleton systems
m_audioSystem = &Audio::instance(); m_audioSystem = AudioManager::get();
m_soundSystem = &SoundEffectManager::instance(); m_soundSystem = &SoundEffectManager::instance();
m_initialized = true; 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 { SDL_Texture* AssetManager::getTexture(const std::string& id) const {
auto it = m_textures.find(id); 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) { bool AssetManager::unloadTexture(const std::string& id) {

View File

@ -7,12 +7,12 @@
#include <memory> #include <memory>
#include <functional> #include <functional>
#include "../interfaces/IAssetLoader.h" #include "../interfaces/IAssetLoader.h"
#include "../interfaces/IAssetLoader.h"
// Forward declarations // Forward declarations
class FontAtlas; class FontAtlas;
class Audio; class Audio;
class SoundEffectManager; class SoundEffectManager;
class IAudioSystem;
/** /**
* AssetManager - Centralized resource management following SOLID principles * AssetManager - Centralized resource management following SOLID principles
@ -121,7 +121,7 @@ private:
// System references // System references
SDL_Renderer* m_renderer; SDL_Renderer* m_renderer;
Audio* m_audioSystem; // Pointer to singleton IAudioSystem* m_audioSystem; // Pointer to audio system (IAudioSystem)
SoundEffectManager* m_soundSystem; // Pointer to singleton SoundEffectManager* m_soundSystem; // Pointer to singleton
// Configuration // Configuration

View File

@ -1,6 +1,8 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include <cstdint>
/** /**
* @brief Abstract interface for audio system operations * @brief Abstract interface for audio system operations
@ -52,4 +54,28 @@ public:
* @return true if music is playing, false otherwise * @return true if music is playing, false otherwise
*/ */
virtual bool isMusicPlaying() const = 0; 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;
}; };

View File

@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) {
} }
bool StateManager::isValidState(AppState state) const { bool StateManager::isValidState(AppState state) const {
// All enum values are currently valid switch (state) {
return static_cast<int>(state) >= static_cast<int>(AppState::Loading) && case AppState::Loading:
static_cast<int>(state) <= static_cast<int>(AppState::GameOver); 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 { bool StateManager::canTransitionTo(AppState newState) const {
@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const {
const char* StateManager::getStateName(AppState state) const { const char* StateManager::getStateName(AppState state) const {
switch (state) { switch (state) {
case AppState::Loading: return "Loading"; case AppState::Loading: return "Loading";
case AppState::Video: return "Video";
case AppState::Menu: return "Menu"; case AppState::Menu: return "Menu";
case AppState::Options: return "Options"; case AppState::Options: return "Options";
case AppState::LevelSelector: return "LevelSelector"; case AppState::LevelSelector: return "LevelSelector";

View File

@ -12,6 +12,7 @@ class RenderManager;
// Application states used across the app // Application states used across the app
enum class AppState { enum class AppState {
Loading, Loading,
Video,
Menu, Menu,
Options, Options,
LevelSelector, LevelSelector,

View File

@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include "audio/Audio.h" #include "audio/Audio.h"
#include "audio/AudioManager.h"
#ifndef M_PI #ifndef M_PI
#define M_PI 3.14159265358979323846 #define M_PI 3.14159265358979323846
@ -266,6 +267,6 @@ void LineEffect::playLineClearSound(int lineCount) {
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample; const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
if (sample && !sample->empty()) { if (sample && !sample->empty()) {
// Mix via shared Audio device so it layers with music // 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);
} }
} }

View File

@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstring>
namespace { namespace {
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell) // NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
@ -41,7 +42,23 @@ CoopGame::CoopGame(int startLevel_) {
reset(startLevel_); reset(startLevel_);
} }
void CoopGame::reset(int 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{}); std::fill(board.begin(), board.end(), Cell{});
rowStates.fill(RowHalfState{}); rowStates.fill(RowHalfState{});
completedLines.clear(); completedLines.clear();
@ -60,7 +77,7 @@ void CoopGame::reset(int startLevel_) {
left = PlayerState{}; left = PlayerState{};
right = PlayerState{ PlayerSide::Right }; right = PlayerState{ PlayerSide::Right };
auto initPlayer = [&](PlayerState& ps) { auto initPlayer = [&](PlayerState& ps, uint32_t seed) {
ps.canHold = true; ps.canHold = true;
ps.hold.type = PIECE_COUNT; ps.hold.type = PIECE_COUNT;
ps.softDropping = false; ps.softDropping = false;
@ -77,16 +94,34 @@ void CoopGame::reset(int startLevel_) {
ps.comboCount = 0; ps.comboCount = 0;
ps.bag.clear(); ps.bag.clear();
ps.next.type = PIECE_COUNT; ps.next.type = PIECE_COUNT;
ps.rng.seed(seed);
refillBag(ps); refillBag(ps);
}; };
initPlayer(left);
initPlayer(right); 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(left);
spawn(right); spawn(right);
updateRowStates(); 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) { void CoopGame::setSoftDropping(PlayerSide side, bool on) {
PlayerState& ps = player(side); PlayerState& ps = player(side);
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; }; auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
@ -103,6 +138,74 @@ void CoopGame::setSoftDropping(PlayerSide side, bool on) {
ps.softDropping = on; 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) { void CoopGame::move(PlayerSide side, int dx) {
PlayerState& ps = player(side); PlayerState& ps = player(side);
if (gameOver || ps.toppedOut) return; if (gameOver || ps.toppedOut) return;

View File

@ -62,9 +62,13 @@ public:
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; } void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
void reset(int startLevel = 0); void reset(int startLevel = 0);
void resetDeterministic(int startLevel, uint32_t seed);
void tickGravity(double frameMs); void tickGravity(double frameMs);
void updateVisualEffects(double frameMs); void updateVisualEffects(double frameMs);
// Determinism / desync detection
uint64_t computeStateHash() const;
// Per-player inputs ----------------------------------------------------- // Per-player inputs -----------------------------------------------------
void setSoftDropping(PlayerSide side, bool on); void setSoftDropping(PlayerSide side, bool on);
void move(PlayerSide side, int dx); void move(PlayerSide side, int dx);
@ -111,6 +115,8 @@ public:
private: private:
static constexpr double LOCK_DELAY_MS = 500.0; 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<Cell, COLS * ROWS> board{};
std::array<RowHalfState, ROWS> rowStates{}; std::array<RowHalfState, ROWS> rowStates{};
PlayerState left{}; PlayerState left{};

View File

@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include "audio/Audio.h" #include "audio/Audio.h"
#include "audio/AudioManager.h"
#include "gameplay/core/Game.h" #include "gameplay/core/Game.h"
#ifndef M_PI #ifndef M_PI
@ -461,7 +462,7 @@ void LineEffect::playLineClearSound(int lineCount) {
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample; const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
if (sample && !sample->empty()) { if (sample && !sample->empty()) {
// Mix via shared Audio device so it layers with music // 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);
} }
} }

View File

@ -1,4 +1,5 @@
#include "GameRenderer.h" #include "GameRenderer.h"
#include "../../renderer/Renderer_iface.h"
#include "SyncLineRenderer.h" #include "SyncLineRenderer.h"
#include "../../gameplay/core/Game.h" #include "../../gameplay/core/Game.h"
@ -248,23 +249,25 @@ static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTe
Uint8 gridAlpha = static_cast<Uint8>(std::lround(255.0f * t)); Uint8 gridAlpha = static_cast<Uint8>(std::lround(255.0f * t));
Uint8 nextAlpha = gridAlpha; // fade new NEXT preview in at same rate as grid Uint8 nextAlpha = gridAlpha; // fade new NEXT preview in at same rate as grid
// Draw preview fade-out // Create renderer wrapper
if (previewAlpha > 0) { auto rwrap = renderer::MakeSDLRenderer(renderer);
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, previewAlpha); // Draw preview fade-out
for (int cy = 0; cy < 4; ++cy) { if (previewAlpha > 0) {
for (int cx = 0; cx < 4; ++cx) { if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, previewAlpha);
if (!Game::cellFilled(s_transport.piece, cx, cy)) continue; for (int cy = 0; cy < 4; ++cy) {
float px = s_transport.startX + static_cast<float>(cx) * s_transport.tileSize; for (int cx = 0; cx < 4; ++cx) {
float py = s_transport.startY + static_cast<float>(cy) * s_transport.tileSize; if (!Game::cellFilled(s_transport.piece, cx, cy)) continue;
GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type); float px = s_transport.startX + static_cast<float>(cx) * s_transport.tileSize;
float py = s_transport.startY + static_cast<float>(cy) * s_transport.tileSize;
GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type);
}
} }
if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
} }
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
}
// Draw grid fade-in (same intensity as next preview fade-in) // Draw grid fade-in (same intensity as next preview fade-in)
if (gridAlpha > 0) { if (gridAlpha > 0) {
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, gridAlpha); if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, gridAlpha);
for (int cy = 0; cy < 4; ++cy) { for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) { for (int cx = 0; cx < 4; ++cx) {
if (!Game::cellFilled(s_transport.piece, cx, cy)) continue; if (!Game::cellFilled(s_transport.piece, cx, cy)) continue;
@ -273,12 +276,12 @@ static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTe
GameRenderer::drawBlockTexturePublic(renderer, blocksTex, gx, gy, s_transport.tileSize, s_transport.piece.type); GameRenderer::drawBlockTexturePublic(renderer, blocksTex, gx, gy, s_transport.tileSize, s_transport.piece.type);
} }
} }
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
} }
// Draw new NEXT preview fade-in (simultaneous) // Draw new NEXT preview fade-in (simultaneous)
if (nextAlpha > 0) { if (nextAlpha > 0) {
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, nextAlpha); if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, nextAlpha);
for (int cy = 0; cy < 4; ++cy) { for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) { for (int cx = 0; cx < 4; ++cx) {
if (!Game::cellFilled(s_transport.nextPiece, cx, cy)) continue; if (!Game::cellFilled(s_transport.nextPiece, cx, cy)) continue;
@ -287,7 +290,7 @@ static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTe
GameRenderer::drawBlockTexturePublic(renderer, blocksTex, nx, ny, s_transport.tileSize, s_transport.nextPiece.type); GameRenderer::drawBlockTexturePublic(renderer, blocksTex, nx, ny, s_transport.tileSize, s_transport.nextPiece.type);
} }
} }
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
} }
if (t >= 1.0f) { if (t >= 1.0f) {
@ -308,16 +311,18 @@ static const SDL_Color COLORS[] = {
}; };
void GameRenderer::drawRect(SDL_Renderer* renderer, float x, float y, float w, float h, SDL_Color c) { void GameRenderer::drawRect(SDL_Renderer* renderer, float x, float y, float w, float h, SDL_Color c) {
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); auto rwrap = renderer::MakeSDLRenderer(renderer);
rwrap->setDrawColor(c);
SDL_FRect fr{x, y, w, h}; SDL_FRect fr{x, y, w, h};
SDL_RenderFillRect(renderer, &fr); rwrap->fillRectF(&fr);
} }
static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float x, float y, float size, const AsteroidCell& cell) { static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float x, float y, float size, const AsteroidCell& cell) {
auto outlineGravity = [&](float inset, SDL_Color color) { auto outlineGravity = [&](float inset, SDL_Color color) {
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); auto rwrap = renderer::MakeSDLRenderer(renderer);
rwrap->setDrawColor(color);
SDL_FRect glow{ x + inset, y + inset, size - inset * 2.0f, size - inset * 2.0f }; SDL_FRect glow{ x + inset, y + inset, size - inset * 2.0f, size - inset * 2.0f };
SDL_RenderRect(renderer, &glow); rwrap->drawRectF(&glow);
}; };
if (asteroidTex) { if (asteroidTex) {
@ -330,9 +335,10 @@ static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float
case AsteroidType::Core: col = 3; break; case AsteroidType::Core: col = 3; break;
} }
int row = std::clamp<int>(cell.visualState, 0, 2); int row = std::clamp<int>(cell.visualState, 0, 2);
auto rwrap = renderer::MakeSDLRenderer(renderer);
SDL_FRect src{ col * SPRITE_SIZE, row * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE }; SDL_FRect src{ col * SPRITE_SIZE, row * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE };
SDL_FRect dst{ x, y, size, size }; SDL_FRect dst{ x, y, size, size };
SDL_RenderTexture(renderer, asteroidTex, &src, &dst); rwrap->renderTexture(asteroidTex, &src, &dst);
if (cell.gravityEnabled) { if (cell.gravityEnabled) {
outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); outlineGravity(2.0f, SDL_Color{255, 230, 120, 180});
@ -355,15 +361,16 @@ static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float
static_cast<Uint8>(base.b * hpScale + 40 * (1.0f - hpScale)), static_cast<Uint8>(base.b * hpScale + 40 * (1.0f - hpScale)),
255 255
}; };
SDL_SetRenderDrawColor(renderer, fill.r, fill.g, fill.b, fill.a); auto rwrap = renderer::MakeSDLRenderer(renderer);
rwrap->setDrawColor(fill);
SDL_FRect body{x, y, size - 1.0f, size - 1.0f}; SDL_FRect body{x, y, size - 1.0f, size - 1.0f};
SDL_RenderFillRect(renderer, &body); rwrap->fillRectF(&body);
SDL_Color outline = base; SDL_Color outline = base;
outline.a = 220; outline.a = 220;
SDL_FRect border{x + 1.0f, y + 1.0f, size - 2.0f, size - 2.0f}; SDL_FRect border{x + 1.0f, y + 1.0f, size - 2.0f, size - 2.0f};
SDL_SetRenderDrawColor(renderer, outline.r, outline.g, outline.b, outline.a); rwrap->setDrawColor(outline);
SDL_RenderRect(renderer, &border); rwrap->drawRectF(&border);
if (cell.gravityEnabled) { if (cell.gravityEnabled) {
outlineGravity(2.0f, SDL_Color{255, 230, 120, 180}); outlineGravity(2.0f, SDL_Color{255, 230, 120, 180});
} }
@ -387,7 +394,8 @@ void GameRenderer::drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksT
SDL_FRect srcRect = {srcX, srcY, srcW, srcH}; SDL_FRect srcRect = {srcX, srcY, srcW, srcH};
SDL_FRect dstRect = {x, y, size, size}; SDL_FRect dstRect = {x, y, size, size};
SDL_RenderTexture(renderer, blocksTex, &srcRect, &dstRect); auto rwrap = renderer::MakeSDLRenderer(renderer);
rwrap->renderTexture(blocksTex, &srcRect, &dstRect);
} }
void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost, float pixelOffsetX, float pixelOffsetY) { void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost, float pixelOffsetX, float pixelOffsetY) {
@ -403,14 +411,17 @@ void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, con
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
// Draw ghost piece as barely visible gray outline // Draw ghost piece as barely visible gray outline
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20); // Very faint gray auto rwrap = renderer::MakeSDLRenderer(renderer);
// Draw ghost fill
SDL_Color ghostFill{180,180,180,20};
rwrap->setDrawColor(ghostFill);
SDL_FRect rect = {px + 2, py + 2, tileSize - 4, tileSize - 4}; SDL_FRect rect = {px + 2, py + 2, tileSize - 4, tileSize - 4};
SDL_RenderFillRect(renderer, &rect); rwrap->fillRectF(&rect);
// Draw thin gray border // Draw thin gray border
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30); SDL_Color ghostBorder{180,180,180,30};
rwrap->setDrawColor(ghostBorder);
SDL_FRect border = {px + 1, py + 1, tileSize - 2, tileSize - 2}; SDL_FRect border = {px + 1, py + 1, tileSize - 2, tileSize - 2};
SDL_RenderRect(renderer, &border); rwrap->drawRectF(&border);
} else { } else {
drawBlockTexture(renderer, blocksTex, px, py, tileSize, piece.type); drawBlockTexture(renderer, blocksTex, px, py, tileSize, piece.type);
} }
@ -426,6 +437,7 @@ void GameRenderer::drawBlockTexturePublic(SDL_Renderer* renderer, SDL_Texture* b
void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) { void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) {
if (pieceType >= PIECE_COUNT) return; if (pieceType >= PIECE_COUNT) return;
auto rwrap = renderer::MakeSDLRenderer(renderer);
// Use the first rotation (index 0) for preview // Use the first rotation (index 0) for preview
Game::Piece previewPiece; Game::Piece previewPiece;
@ -461,7 +473,7 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex
// Use semi-transparent alpha for preview blocks // Use semi-transparent alpha for preview blocks
Uint8 previewAlpha = 180; Uint8 previewAlpha = 180;
if (blocksTex) { if (blocksTex) {
SDL_SetTextureAlphaMod(blocksTex, previewAlpha); rwrap->setTextureAlphaMod(blocksTex, previewAlpha);
} }
for (int cy = 0; cy < 4; ++cy) { for (int cy = 0; cy < 4; ++cy) {
@ -476,7 +488,7 @@ void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex
// Reset alpha // Reset alpha
if (blocksTex) { if (blocksTex) {
SDL_SetTextureAlphaMod(blocksTex, 255); rwrap->setTextureAlphaMod(blocksTex, 255);
} }
} }
@ -496,6 +508,8 @@ void GameRenderer::renderNextPanel(
return; return;
} }
auto rwrap = renderer::MakeSDLRenderer(renderer);
const SDL_Color gridBorderColor{60, 80, 160, 255}; // matches main grid outline const SDL_Color gridBorderColor{60, 80, 160, 255}; // matches main grid outline
const SDL_Color bayColor{8, 12, 24, 235}; const SDL_Color bayColor{8, 12, 24, 235};
const SDL_Color bayOutline{25, 62, 86, 220}; const SDL_Color bayOutline{25, 62, 86, 220};
@ -505,25 +519,24 @@ void GameRenderer::renderNextPanel(
// the panel rectangle and skip the custom background/frame drawing. // the panel rectangle and skip the custom background/frame drawing.
if (nextPanelTex) { if (nextPanelTex) {
SDL_FRect dst{panelX, panelY, panelW, panelH}; SDL_FRect dst{panelX, panelY, panelW, panelH};
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &dst); rwrap->renderTexture(nextPanelTex, nullptr, &dst);
// Draw the panel label over the texture — user requested visible label
const float labelPad = tileSize * 0.25f; const float labelPad = tileSize * 0.25f;
pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor); pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor);
} else { } else {
SDL_FRect bayRect{panelX, panelY, panelW, panelH}; SDL_FRect bayRect{panelX, panelY, panelW, panelH};
SDL_SetRenderDrawColor(renderer, bayColor.r, bayColor.g, bayColor.b, bayColor.a); rwrap->setDrawColor(bayColor);
SDL_RenderFillRect(renderer, &bayRect); rwrap->fillRectF(&bayRect);
SDL_FRect thinOutline{panelX - 1.0f, panelY - 1.0f, panelW + 2.0f, panelH + 2.0f}; SDL_FRect thinOutline{panelX - 1.0f, panelY - 1.0f, panelW + 2.0f, panelH + 2.0f};
auto drawOutlineNoBottom = [&](const SDL_FRect& rect, SDL_Color color) { auto drawOutlineNoBottom = [&](const SDL_FRect& rect, SDL_Color color) {
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); rwrap->setDrawColor(color);
const float left = rect.x; const float left = rect.x;
const float top = rect.y; const float top = rect.y;
const float right = rect.x + rect.w; const float right = rect.x + rect.w;
const float bottom = rect.y + rect.h; const float bottom = rect.y + rect.h;
SDL_RenderLine(renderer, left, top, right, top); // top edge rwrap->renderLine(left, top, right, top); // top edge
SDL_RenderLine(renderer, left, top, left, bottom); // left edge rwrap->renderLine(left, top, left, bottom); // left edge
SDL_RenderLine(renderer, right, top, right, bottom); // right edge rwrap->renderLine(right, top, right, bottom); // right edge
}; };
drawOutlineNoBottom(thinOutline, gridBorderColor); drawOutlineNoBottom(thinOutline, gridBorderColor);
@ -641,11 +654,12 @@ void GameRenderer::renderPlayingState(
float contentOffsetX = (winW - contentW) * 0.5f / contentScale; float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale; float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
// Helper lambda for drawing rectangles with content offset // Renderer wrapper and helper lambda for drawing rectangles with content offset
auto rwrap = renderer::MakeSDLRenderer(renderer);
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) { auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); rwrap->setDrawColor(c);
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h}; SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
SDL_RenderFillRect(renderer, &fr); rwrap->fillRectF(&fr);
}; };
// Responsive layout that scales with window size while maintaining margins // Responsive layout that scales with window size while maintaining margins
@ -747,28 +761,28 @@ void GameRenderer::renderPlayingState(
scaledW, scaledW,
scaledH scaledH
}; };
SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &dstF); rwrap->renderTexture(statisticsPanelTex, nullptr, &dstF);
} }
} else { } else {
// Fallback: render entire texture stretched to panel // Fallback: render entire texture stretched to panel
SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &blocksPanelBg); rwrap->renderTexture(statisticsPanelTex, nullptr, &blocksPanelBg);
} }
} else if (scorePanelTex) { } else if (scorePanelTex) {
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &blocksPanelBg); rwrap->renderTexture(scorePanelTex, nullptr, &blocksPanelBg);
} else { } else {
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); rwrap->setDrawColor(SDL_Color{12, 18, 32, 205});
SDL_RenderFillRect(renderer, &blocksPanelBg); rwrap->fillRectF(&blocksPanelBg);
} }
// Draw grid lines // Draw grid lines
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); rwrap->setDrawColor(SDL_Color{40, 45, 60, 255});
for (int x = 1; x < Game::COLS; ++x) { for (int x = 1; x < Game::COLS; ++x) {
float lineX = gridX + x * finalBlockSize; float lineX = gridX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); rwrap->renderLine(lineX, gridY, lineX, gridY + GRID_H);
} }
for (int y = 1; y < Game::ROWS; ++y) { for (int y = 1; y < Game::ROWS; ++y) {
float lineY = gridY + y * finalBlockSize; float lineY = gridY + y * finalBlockSize;
SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY); rwrap->renderLine(gridX, lineY, gridX + GRID_W, lineY);
} }
if (!s_starfieldInitialized) { if (!s_starfieldInitialized) {
@ -817,6 +831,7 @@ void GameRenderer::renderPlayingState(
SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE; SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE;
SDL_GetRenderDrawBlendMode(renderer, &oldBlend); SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
// rwrap already declared near function start; reuse it here.
// Add a small, smooth sub-pixel jitter to the starfield origin so the // Add a small, smooth sub-pixel jitter to the starfield origin so the
// brightest star doesn't permanently sit exactly at the visual center. // brightest star doesn't permanently sit exactly at the visual center.
{ {
@ -933,10 +948,10 @@ void GameRenderer::renderPlayingState(
float pulse = 0.5f + 0.5f * std::sin(sp.pulse); float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f); Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha); rwrap->setDrawColor(SDL_Color{sp.color.r, sp.color.g, sp.color.b, alpha});
float half = sp.size * 0.5f; float half = sp.size * 0.5f;
SDL_FRect fr{gridX + sp.x - half, gridY + sp.y - half, sp.size, sp.size}; SDL_FRect fr{gridX + sp.x - half, gridY + sp.y - half, sp.size, sp.size};
SDL_RenderFillRect(renderer, &fr); rwrap->fillRectF(&fr);
++it; ++it;
} }
@ -949,11 +964,11 @@ void GameRenderer::renderPlayingState(
// Draw a small filled connector to visually merge NEXT panel and grid border // Draw a small filled connector to visually merge NEXT panel and grid border
// If an external NEXT panel texture is used, skip the connector to avoid // If an external NEXT panel texture is used, skip the connector to avoid
// drawing a visible seam under the image/artwork. // drawing a visible seam under the image/artwork.
if (!nextPanelTex) { if (!nextPanelTex) {
SDL_SetRenderDrawColor(renderer, 60, 80, 160, 255); // same as grid border rwrap->setDrawColor(SDL_Color{60, 80, 160, 255}); // same as grid border
float connectorY = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT; // bottom of next panel (near grid top) float connectorY = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT; // bottom of next panel (near grid top)
SDL_FRect connRect{ NEXT_PANEL_X, connectorY - 1.0f, NEXT_PANEL_WIDTH, 2.0f }; SDL_FRect connRect{ NEXT_PANEL_X, connectorY - 1.0f, NEXT_PANEL_WIDTH, 2.0f };
SDL_RenderFillRect(renderer, &connRect); rwrap->fillRectF(&connRect);
} }
// Draw transport effect if active (renders the moving piece and trail) // Draw transport effect if active (renders the moving piece and trail)
@ -1164,27 +1179,27 @@ void GameRenderer::renderPlayingState(
} }
if (asteroidsTex && spawnAlpha < 1.0f) { if (asteroidsTex && spawnAlpha < 1.0f) {
SDL_SetTextureAlphaMod(asteroidsTex, static_cast<Uint8>(std::clamp(spawnAlpha, 0.0f, 1.0f) * 255.0f)); rwrap->setTextureAlphaMod(asteroidsTex, static_cast<Uint8>(std::clamp(spawnAlpha, 0.0f, 1.0f) * 255.0f));
} }
float size = finalBlockSize * spawnScale * clearScale; float size = finalBlockSize * spawnScale * clearScale;
float offset = (finalBlockSize - size) * 0.5f; float offset = (finalBlockSize - size) * 0.5f;
if (asteroidsTex && clearAlpha < 1.0f) { if (asteroidsTex && clearAlpha < 1.0f) {
Uint8 alpha = static_cast<Uint8>(std::clamp(spawnAlpha * clearAlpha, 0.0f, 1.0f) * 255.0f); Uint8 alpha = static_cast<Uint8>(std::clamp(spawnAlpha * clearAlpha, 0.0f, 1.0f) * 255.0f);
SDL_SetTextureAlphaMod(asteroidsTex, alpha); rwrap->setTextureAlphaMod(asteroidsTex, alpha);
} }
drawAsteroid(renderer, asteroidsTex, bx + offset, by + offset, size, cell); drawAsteroid(renderer, asteroidsTex, bx + offset, by + offset, size, cell);
if (asteroidsTex && (spawnAlpha < 1.0f || clearAlpha < 1.0f)) { if (asteroidsTex && (spawnAlpha < 1.0f || clearAlpha < 1.0f)) {
SDL_SetTextureAlphaMod(asteroidsTex, 255); rwrap->setTextureAlphaMod(asteroidsTex, 255);
} }
} else { } else {
if (blocksTex && clearAlpha < 1.0f) { if (blocksTex && clearAlpha < 1.0f) {
SDL_SetTextureAlphaMod(blocksTex, static_cast<Uint8>(std::clamp(clearAlpha, 0.0f, 1.0f) * 255.0f)); rwrap->setTextureAlphaMod(blocksTex, static_cast<Uint8>(std::clamp(clearAlpha, 0.0f, 1.0f) * 255.0f));
} }
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize * clearScale, v - 1); drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize * clearScale, v - 1);
if (blocksTex && clearAlpha < 1.0f) { if (blocksTex && clearAlpha < 1.0f) {
SDL_SetTextureAlphaMod(blocksTex, 255); rwrap->setTextureAlphaMod(blocksTex, 255);
} }
} }
} }
@ -1209,7 +1224,7 @@ void GameRenderer::renderPlayingState(
s.y += s.vy * sparkDeltaMs; s.y += s.vy * sparkDeltaMs;
float lifeRatio = std::clamp(static_cast<float>(s.lifeMs / s.maxLifeMs), 0.0f, 1.0f); float lifeRatio = std::clamp(static_cast<float>(s.lifeMs / s.maxLifeMs), 0.0f, 1.0f);
Uint8 alpha = static_cast<Uint8>(lifeRatio * 200.0f); Uint8 alpha = static_cast<Uint8>(lifeRatio * 200.0f);
SDL_SetRenderDrawColor(renderer, s.color.r, s.color.g, s.color.b, alpha); rwrap->setDrawColor(SDL_Color{s.color.r, s.color.g, s.color.b, alpha});
float size = s.size * (0.7f + (1.0f - lifeRatio) * 0.8f); float size = s.size * (0.7f + (1.0f - lifeRatio) * 0.8f);
SDL_FRect shardRect{ SDL_FRect shardRect{
s.x - size * 0.5f, s.x - size * 0.5f,
@ -1217,7 +1232,7 @@ void GameRenderer::renderPlayingState(
size, size,
size * 1.4f size * 1.4f
}; };
SDL_RenderFillRect(renderer, &shardRect); rwrap->fillRectF(&shardRect);
++shardIt; ++shardIt;
} }
@ -1238,14 +1253,14 @@ void GameRenderer::renderPlayingState(
SDL_Color c = b.color; SDL_Color c = b.color;
Uint8 a = static_cast<Uint8>(alpha * 220.0f); Uint8 a = static_cast<Uint8>(alpha * 220.0f);
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a); rwrap->setDrawColor(SDL_Color{c.r, c.g, c.b, a});
SDL_FRect outer{ SDL_FRect outer{
b.x - radius + jitter, b.x - radius + jitter,
b.y - radius + jitter, b.y - radius + jitter,
radius * 2.0f, radius * 2.0f,
radius * 2.0f radius * 2.0f
}; };
SDL_RenderRect(renderer, &outer); rwrap->drawRectF(&outer);
SDL_FRect inner{ SDL_FRect inner{
b.x - (radius - thickness), b.x - (radius - thickness),
@ -1253,8 +1268,8 @@ void GameRenderer::renderPlayingState(
(radius - thickness) * 2.0f, (radius - thickness) * 2.0f,
(radius - thickness) * 2.0f (radius - thickness) * 2.0f
}; };
SDL_SetRenderDrawColor(renderer, 255, 255, 255, static_cast<Uint8>(a * 0.9f)); rwrap->setDrawColor(SDL_Color{255, 255, 255, static_cast<Uint8>(a * 0.9f)});
SDL_RenderRect(renderer, &inner); rwrap->drawRectF(&inner);
++it; ++it;
} }
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
@ -1275,14 +1290,14 @@ void GameRenderer::renderPlayingState(
} }
float lifeRatio = spark.lifeMs / spark.maxLifeMs; float lifeRatio = spark.lifeMs / spark.maxLifeMs;
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f); Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha); rwrap->setDrawColor(SDL_Color{spark.color.r, spark.color.g, spark.color.b, alpha});
SDL_FRect sparkRect{ SDL_FRect sparkRect{
spark.x - spark.size * 0.5f, spark.x - spark.size * 0.5f,
spark.y - spark.size * 0.5f, spark.y - spark.size * 0.5f,
spark.size, spark.size,
spark.size * 1.4f spark.size * 1.4f
}; };
SDL_RenderFillRect(renderer, &sparkRect); rwrap->fillRectF(&sparkRect);
++it; ++it;
} }
} }
@ -1526,9 +1541,9 @@ void GameRenderer::renderPlayingState(
float barW = numbersW; float barW = numbersW;
float barY = numbersY + numbersH + 8.0f; float barY = numbersY + numbersH + 8.0f;
SDL_SetRenderDrawColor(renderer, 24, 80, 120, 220); rwrap->setDrawColor(SDL_Color{24, 80, 120, 220});
SDL_FRect track{barX, barY, barW, barHeight}; SDL_FRect track{barX, barY, barW, barHeight};
SDL_RenderFillRect(renderer, &track); rwrap->fillRectF(&track);
// Fill color brightness based on usage and highlight for top piece // Fill color brightness based on usage and highlight for top piece
float strength = (totalBlocks > 0) ? (float(blockCounts[i]) / float(totalBlocks)) : 0.0f; float strength = (totalBlocks > 0) ? (float(blockCounts[i]) / float(totalBlocks)) : 0.0f;
@ -1542,9 +1557,9 @@ void GameRenderer::renderPlayingState(
}; };
float fillW = barW * std::clamp(strength, 0.0f, 1.0f); float fillW = barW * std::clamp(strength, 0.0f, 1.0f);
SDL_SetRenderDrawColor(renderer, fillC.r, fillC.g, fillC.b, fillC.a); rwrap->setDrawColor(SDL_Color{fillC.r, fillC.g, fillC.b, fillC.a});
SDL_FRect fill{barX, barY, fillW, barHeight}; SDL_FRect fill{barX, barY, fillW, barHeight};
SDL_RenderFillRect(renderer, &fill); rwrap->fillRectF(&fill);
// Advance cursor to next row: after bar + gap (leave more space between blocks) // Advance cursor to next row: after bar + gap (leave more space between blocks)
yCursor = barY + barHeight + rowGap + 6.0f; yCursor = barY + barHeight + rowGap + 6.0f;
@ -1719,10 +1734,10 @@ void GameRenderer::renderPlayingState(
SDL_FRect statsBg{statsPanelLeft, statsPanelTop, statsPanelWidth, statsPanelHeight}; SDL_FRect statsBg{statsPanelLeft, statsPanelTop, statsPanelWidth, statsPanelHeight};
if (scorePanelTex) { if (scorePanelTex) {
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &statsBg); rwrap->renderTexture(scorePanelTex, nullptr, &statsBg);
} else { } else {
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); rwrap->setDrawColor(SDL_Color{12, 18, 32, 205});
SDL_RenderFillRect(renderer, &statsBg); rwrap->fillRectF(&statsBg);
} }
scorePanelMetricsValid = true; scorePanelMetricsValid = true;
@ -1810,12 +1825,12 @@ void GameRenderer::renderPlayingState(
SDL_FRect panelDst{panelX, panelY, panelW, panelH}; SDL_FRect panelDst{panelX, panelY, panelW, panelH};
SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND); SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND);
SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR); SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR);
SDL_RenderTexture(renderer, holdPanelTex, nullptr, &panelDst); rwrap->renderTexture(holdPanelTex, nullptr, &panelDst);
} else { } else {
// fallback: draw a dark panel rect so UI is visible even without texture // fallback: draw a dark panel rect so UI is visible even without texture
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 220); rwrap->setDrawColor(SDL_Color{12, 18, 32, 220});
SDL_FRect panelDst{panelX, panelY, panelW, panelH}; SDL_FRect panelDst{panelX, panelY, panelW, panelH};
SDL_RenderFillRect(renderer, &panelDst); rwrap->fillRectF(&panelDst);
} }
// Display "HOLD" label on right side // Display "HOLD" label on right side
@ -1854,6 +1869,8 @@ void GameRenderer::renderCoopPlayingState(
) { ) {
if (!renderer || !game || !pixelFont) return; if (!renderer || !game || !pixelFont) return;
auto rwrap = renderer::MakeSDLRenderer(renderer);
static SyncLineRenderer s_syncLine; static SyncLineRenderer s_syncLine;
static bool s_lastHadCompletedLines = false; static bool s_lastHadCompletedLines = false;
@ -1892,9 +1909,9 @@ void GameRenderer::renderCoopPlayingState(
float contentOffsetY = (winH - contentH) * 0.5f / contentScale; float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) { auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); rwrap->setDrawColor(c);
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h}; SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
SDL_RenderFillRect(renderer, &fr); rwrap->fillRectF(&fr);
}; };
static constexpr float COOP_GAP_PX = 20.0f; static constexpr float COOP_GAP_PX = 20.0f;
@ -1967,19 +1984,19 @@ void GameRenderer::renderCoopPlayingState(
}; };
// Grid lines (draw per-half so the gap is clean) // Grid lines (draw per-half so the gap is clean)
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255); rwrap->setDrawColor(SDL_Color{40, 45, 60, 255});
for (int x = 1; x < 10; ++x) { for (int x = 1; x < 10; ++x) {
float lineX = gridX + x * finalBlockSize; float lineX = gridX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); rwrap->renderLine(lineX, gridY, lineX, gridY + GRID_H);
} }
for (int x = 1; x < 10; ++x) { for (int x = 1; x < 10; ++x) {
float lineX = gridX + HALF_W + COOP_GAP_PX + x * finalBlockSize; float lineX = gridX + HALF_W + COOP_GAP_PX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H); rwrap->renderLine(lineX, gridY, lineX, gridY + GRID_H);
} }
for (int y = 1; y < CoopGame::ROWS; ++y) { for (int y = 1; y < CoopGame::ROWS; ++y) {
float lineY = gridY + y * finalBlockSize; float lineY = gridY + y * finalBlockSize;
SDL_RenderLine(renderer, gridX, lineY, gridX + HALF_W, lineY); rwrap->renderLine(gridX, lineY, gridX + HALF_W, lineY);
SDL_RenderLine(renderer, gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY); rwrap->renderLine(gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY);
} }
// In-grid 3D starfield + ambient sparkles (match classic feel, per-half) // In-grid 3D starfield + ambient sparkles (match classic feel, per-half)
@ -2164,10 +2181,10 @@ void GameRenderer::renderCoopPlayingState(
float pulse = 0.5f + 0.5f * std::sin(sp.pulse); float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f); Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha); rwrap->setDrawColor(SDL_Color{sp.color.r, sp.color.g, sp.color.b, alpha});
float half = sp.size * 0.5f; float half = sp.size * 0.5f;
SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size }; SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size };
SDL_RenderFillRect(renderer, &fr); rwrap->fillRectF(&fr);
++it; ++it;
} }
} }
@ -2186,14 +2203,14 @@ void GameRenderer::renderCoopPlayingState(
} }
float lifeRatio = spark.lifeMs / spark.maxLifeMs; float lifeRatio = spark.lifeMs / spark.maxLifeMs;
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f); Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha); rwrap->setDrawColor(SDL_Color{spark.color.r, spark.color.g, spark.color.b, alpha});
SDL_FRect sparkRect{ SDL_FRect sparkRect{
spark.x - spark.size * 0.5f, spark.x - spark.size * 0.5f,
spark.y - spark.size * 0.5f, spark.y - spark.size * 0.5f,
spark.size, spark.size,
spark.size * 1.4f spark.size * 1.4f
}; };
SDL_RenderFillRect(renderer, &sparkRect); rwrap->fillRectF(&sparkRect);
++it; ++it;
} }
} }
@ -2225,17 +2242,17 @@ void GameRenderer::renderCoopPlayingState(
} }
if (rs.leftFull && rs.rightFull) { if (rs.leftFull && rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45); rwrap->setDrawColor(SDL_Color{140, 210, 255, 45});
SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize}; SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize};
SDL_RenderFillRect(renderer, &frL); rwrap->fillRectF(&frL);
SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize}; SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize};
SDL_RenderFillRect(renderer, &frR); rwrap->fillRectF(&frR);
} else if (rs.leftFull ^ rs.rightFull) { } else if (rs.leftFull ^ rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35); rwrap->setDrawColor(SDL_Color{90, 140, 220, 35});
float w = HALF_W; float w = HALF_W;
float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX); float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX);
SDL_FRect fr{x, rowY, w, finalBlockSize}; SDL_FRect fr{x, rowY, w, finalBlockSize};
SDL_RenderFillRect(renderer, &fr); rwrap->fillRectF(&fr);
} }
} }
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
@ -2432,7 +2449,7 @@ void GameRenderer::renderCoopPlayingState(
float elapsed = static_cast<float>(nowTicks - sf.startTick); float elapsed = static_cast<float>(nowTicks - sf.startTick);
float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f); float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t)); Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha); if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, alpha);
int minCy = 4; int minCy = 4;
int maxCy = -1; int maxCy = -1;
@ -2479,7 +2496,7 @@ void GameRenderer::renderCoopPlayingState(
drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type); drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type);
} }
} }
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255); if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
// End fade after duration, but never stop while we are pinning (otherwise // End fade after duration, but never stop while we are pinning (otherwise
// I can briefly disappear until it becomes visible in the real grid). // I can briefly disappear until it becomes visible in the real grid).
@ -2499,12 +2516,12 @@ void GameRenderer::renderCoopPlayingState(
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second; float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
if (isGhost) { if (isGhost) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20); rwrap->setDrawColor(SDL_Color{180, 180, 180, 20});
SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f}; SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
SDL_RenderFillRect(renderer, &rect); rwrap->fillRectF(&rect);
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30); rwrap->setDrawColor(SDL_Color{180, 180, 180, 30});
SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f}; SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
SDL_RenderRect(renderer, &border); rwrap->drawRectF(&border);
} else { } else {
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type); drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
} }
@ -2579,7 +2596,7 @@ void GameRenderer::renderCoopPlayingState(
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) { auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH }; SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
if (nextPanelTex) { if (nextPanelTex) {
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel); rwrap->renderTexture(nextPanelTex, nullptr, &panel);
} else { } else {
drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200}); drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
} }
@ -2707,10 +2724,10 @@ void GameRenderer::renderCoopPlayingState(
float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX; float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX;
SDL_FRect panelBg{ panelX, panelY, panelW, panelH }; SDL_FRect panelBg{ panelX, panelY, panelW, panelH };
if (scorePanelTex) { if (scorePanelTex) {
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &panelBg); rwrap->renderTexture(scorePanelTex, nullptr, &panelBg);
} else { } else {
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205); rwrap->setDrawColor(SDL_Color{12, 18, 32, 205});
SDL_RenderFillRect(renderer, &panelBg); rwrap->fillRectF(&panelBg);
} }
float textDrawX = panelX + statsPanelPadLeft; float textDrawX = panelX + statsPanelPadLeft;
@ -2777,9 +2794,10 @@ void GameRenderer::renderExitPopup(
SDL_SetRenderViewport(renderer, nullptr); SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.0f, 1.0f); SDL_SetRenderScale(renderer, 1.0f, 1.0f);
SDL_SetRenderDrawColor(renderer, 2, 4, 12, 210); auto rwrap = renderer::MakeSDLRenderer(renderer);
rwrap->setDrawColor(SDL_Color{2, 4, 12, 210});
SDL_FRect fullWin{0.0f, 0.0f, winW, winH}; SDL_FRect fullWin{0.0f, 0.0f, winW, winH};
SDL_RenderFillRect(renderer, &fullWin); rwrap->fillRectF(&fullWin);
const float scale = std::max(0.8f, logicalScale); const float scale = std::max(0.8f, logicalScale);
const float panelW = 740.0f * scale; const float panelW = 740.0f * scale;
@ -2797,8 +2815,8 @@ void GameRenderer::renderExitPopup(
panel.w + 4.0f * scale, panel.w + 4.0f * scale,
panel.h + 4.0f * scale panel.h + 4.0f * scale
}; };
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 140); rwrap->setDrawColor(SDL_Color{0, 0, 0, 140});
SDL_RenderFillRect(renderer, &shadow); rwrap->fillRectF(&shadow);
const std::array<SDL_Color, 3> panelLayers{ const std::array<SDL_Color, 3> panelLayers{
SDL_Color{7, 10, 22, 255}, SDL_Color{7, 10, 22, 255},
@ -2814,12 +2832,12 @@ void GameRenderer::renderExitPopup(
panel.h - inset * 2.0f panel.h - inset * 2.0f
}; };
SDL_Color c = panelLayers[i]; SDL_Color c = panelLayers[i];
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); rwrap->setDrawColor(c);
SDL_RenderFillRect(renderer, &layer); rwrap->fillRectF(&layer);
} }
SDL_SetRenderDrawColor(renderer, 60, 90, 150, 255); rwrap->setDrawColor(SDL_Color{60, 90, 150, 255});
SDL_RenderRect(renderer, &panel); rwrap->drawRectF(&panel);
SDL_FRect insetFrame{ SDL_FRect insetFrame{
panel.x + 10.0f * scale, panel.x + 10.0f * scale,
@ -2827,8 +2845,8 @@ void GameRenderer::renderExitPopup(
panel.w - 20.0f * scale, panel.w - 20.0f * scale,
panel.h - 20.0f * scale panel.h - 20.0f * scale
}; };
SDL_SetRenderDrawColor(renderer, 24, 45, 84, 255); rwrap->setDrawColor(SDL_Color{24, 45, 84, 255});
SDL_RenderRect(renderer, &insetFrame); rwrap->drawRectF(&insetFrame);
const float contentPad = 44.0f * scale; const float contentPad = 44.0f * scale;
float textX = panel.x + contentPad; float textX = panel.x + contentPad;
@ -2842,9 +2860,9 @@ void GameRenderer::renderExitPopup(
pixelFont->draw(renderer, textX, cursorY, title, titleScale, SDL_Color{255, 224, 130, 255}); pixelFont->draw(renderer, textX, cursorY, title, titleScale, SDL_Color{255, 224, 130, 255});
cursorY += titleH + 18.0f * scale; cursorY += titleH + 18.0f * scale;
SDL_SetRenderDrawColor(renderer, 32, 64, 110, 210); rwrap->setDrawColor(SDL_Color{32, 64, 110, 210});
SDL_FRect divider{textX, cursorY, contentWidth, 2.0f * scale}; SDL_FRect divider{textX, cursorY, contentWidth, 2.0f * scale};
SDL_RenderFillRect(renderer, &divider); rwrap->fillRectF(&divider);
cursorY += 26.0f * scale; cursorY += 26.0f * scale;
const std::array<const char*, 2> lines{ const std::array<const char*, 2> lines{
@ -2885,29 +2903,29 @@ void GameRenderer::renderExitPopup(
SDL_Color border = selected ? SDL_Color{255, 225, 150, 255} : SDL_Color{90, 120, 170, 255}; SDL_Color border = selected ? SDL_Color{255, 225, 150, 255} : SDL_Color{90, 120, 170, 255};
SDL_Color topEdge = SDL_Color{Uint8(std::min(255, body.r + 20)), Uint8(std::min(255, body.g + 20)), Uint8(std::min(255, body.b + 20)), 255}; SDL_Color topEdge = SDL_Color{Uint8(std::min(255, body.r + 20)), Uint8(std::min(255, body.g + 20)), Uint8(std::min(255, body.b + 20)), 255};
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 110); rwrap->setDrawColor(SDL_Color{0, 0, 0, 110});
SDL_FRect btnShadow{btn.x + 6.0f * scale, btn.y + 8.0f * scale, btn.w, btn.h}; SDL_FRect btnShadow{btn.x + 6.0f * scale, btn.y + 8.0f * scale, btn.w, btn.h};
SDL_RenderFillRect(renderer, &btnShadow); rwrap->fillRectF(&btnShadow);
SDL_SetRenderDrawColor(renderer, body.r, body.g, body.b, body.a); rwrap->setDrawColor(body);
SDL_RenderFillRect(renderer, &btn); rwrap->fillRectF(&btn);
SDL_FRect topStrip{btn.x, btn.y, btn.w, 6.0f * scale}; SDL_FRect topStrip{btn.x, btn.y, btn.w, 6.0f * scale};
SDL_SetRenderDrawColor(renderer, topEdge.r, topEdge.g, topEdge.b, topEdge.a); rwrap->setDrawColor(topEdge);
SDL_RenderFillRect(renderer, &topStrip); rwrap->fillRectF(&topStrip);
SDL_SetRenderDrawColor(renderer, border.r, border.g, border.b, border.a); rwrap->setDrawColor(border);
SDL_RenderRect(renderer, &btn); rwrap->drawRectF(&btn);
if (selected) { if (selected) {
SDL_SetRenderDrawColor(renderer, 255, 230, 160, 90); rwrap->setDrawColor(SDL_Color{255, 230, 160, 90});
SDL_FRect glow{ SDL_FRect glow{
btn.x - 6.0f * scale, btn.x - 6.0f * scale,
btn.y - 6.0f * scale, btn.y - 6.0f * scale,
btn.w + 12.0f * scale, btn.w + 12.0f * scale,
btn.h + 12.0f * scale btn.h + 12.0f * scale
}; };
SDL_RenderRect(renderer, &glow); rwrap->drawRectF(&glow);
} }
const float labelScale = 1.35f * scale; const float labelScale = 1.35f * scale;
@ -2948,9 +2966,10 @@ void GameRenderer::renderPauseOverlay(
SDL_SetRenderScale(renderer, 1.0f, 1.0f); SDL_SetRenderScale(renderer, 1.0f, 1.0f);
// Draw full screen overlay (darken) // Draw full screen overlay (darken)
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); auto rwrap = renderer::MakeSDLRenderer(renderer);
rwrap->setDrawColor(SDL_Color{0, 0, 0, 180});
SDL_FRect pauseOverlay{0, 0, winW, winH}; SDL_FRect pauseOverlay{0, 0, winW, winH};
SDL_RenderFillRect(renderer, &pauseOverlay); rwrap->fillRectF(&pauseOverlay);
// Draw centered text // Draw centered text
const char* pausedText = "PAUSED"; const char* pausedText = "PAUSED";

59
src/logic/Board.cpp Normal file
View 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
View 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

View 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
View 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
View 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;
};

38
src/renderer/Renderer.h Normal file
View File

@ -0,0 +1,38 @@
// Renderer abstraction (minimal scaffold)
#pragma once
#include <memory>
#include <SDL3/SDL.h>
namespace renderer {
class Renderer {
public:
virtual ~Renderer() = default;
// Create/destroy textures
virtual SDL_Texture* createTextureFromSurface(SDL_Surface* surf) = 0;
virtual void destroyTexture(SDL_Texture* tex) = 0;
// Draw operations (minimal)
// Copy a texture (integer rects)
virtual void copy(SDL_Texture* tex, const SDL_Rect* src, const SDL_Rect* dst) = 0;
// Copy a texture using floating-point rects (SDL_FRect)
virtual void renderTexture(SDL_Texture* tex, const SDL_FRect* src, const SDL_FRect* dst) = 0;
// Set alpha modulation on a texture
virtual void setTextureAlphaMod(SDL_Texture* tex, Uint8 a) = 0;
// Draw a line (floating-point coordinates)
virtual void renderLine(float x1, float y1, float x2, float y2) = 0;
// Set draw color and draw filled/floating rects
virtual void clear(const SDL_Color& color) = 0;
virtual void setDrawColor(const SDL_Color& color) = 0;
virtual void fillRectF(const SDL_FRect* rect) = 0;
virtual void drawRectF(const SDL_FRect* rect) = 0;
virtual void present() = 0;
};
// Factory helper implemented by SDL-specific backend
std::unique_ptr<Renderer> MakeSDLRenderer(SDL_Renderer* rdr);
} // namespace renderer

View File

@ -0,0 +1,27 @@
// Clean renderer interface for local use
#pragma once
#include <memory>
#include <SDL3/SDL.h>
namespace renderer {
class Renderer {
public:
virtual ~Renderer() = default;
virtual SDL_Texture* createTextureFromSurface(SDL_Surface* surf) = 0;
virtual void destroyTexture(SDL_Texture* tex) = 0;
virtual void copy(SDL_Texture* tex, const SDL_Rect* src, const SDL_Rect* dst) = 0;
virtual void renderTexture(SDL_Texture* tex, const SDL_FRect* src, const SDL_FRect* dst) = 0;
virtual void setTextureAlphaMod(SDL_Texture* tex, Uint8 a) = 0;
virtual void renderLine(float x1, float y1, float x2, float y2) = 0;
virtual void clear(const SDL_Color& color) = 0;
virtual void setDrawColor(const SDL_Color& color) = 0;
virtual void fillRectF(const SDL_FRect* rect) = 0;
virtual void drawRectF(const SDL_FRect* rect) = 0;
virtual void present() = 0;
};
std::unique_ptr<Renderer> MakeSDLRenderer(SDL_Renderer* rdr);
} // namespace renderer

View File

@ -0,0 +1,80 @@
#include "Renderer_iface.h"
#include <SDL3/SDL.h>
namespace renderer {
class SDLRendererImpl : public Renderer {
public:
explicit SDLRendererImpl(SDL_Renderer* rdr) : rdr_(rdr) {}
~SDLRendererImpl() override = default;
SDL_Texture* createTextureFromSurface(SDL_Surface* surf) override {
if (!rdr_ || !surf) return nullptr;
return SDL_CreateTextureFromSurface(rdr_, surf);
}
void destroyTexture(SDL_Texture* tex) override {
if (tex) SDL_DestroyTexture(tex);
}
void copy(SDL_Texture* tex, const SDL_Rect* src, const SDL_Rect* dst) override {
if (!rdr_ || !tex) return;
// Convert integer rects to float rects and call SDL_RenderTexture (SDL3 API)
SDL_FRect fs{}; SDL_FRect fd{};
const SDL_FRect* ps = nullptr;
const SDL_FRect* pd = nullptr;
if (src) { fs.x = static_cast<float>(src->x); fs.y = static_cast<float>(src->y); fs.w = static_cast<float>(src->w); fs.h = static_cast<float>(src->h); ps = &fs; }
if (dst) { fd.x = static_cast<float>(dst->x); fd.y = static_cast<float>(dst->y); fd.w = static_cast<float>(dst->w); fd.h = static_cast<float>(dst->h); pd = &fd; }
SDL_RenderTexture(rdr_, tex, ps, pd);
}
void renderTexture(SDL_Texture* tex, const SDL_FRect* src, const SDL_FRect* dst) override {
if (!rdr_ || !tex) return;
SDL_RenderTexture(rdr_, tex, src, dst);
}
void setTextureAlphaMod(SDL_Texture* tex, Uint8 a) override {
if (!tex) return;
SDL_SetTextureAlphaMod(tex, a);
}
void clear(const SDL_Color& color) override {
if (!rdr_) return;
SDL_SetRenderDrawColor(rdr_, color.r, color.g, color.b, color.a);
SDL_RenderClear(rdr_);
}
void setDrawColor(const SDL_Color& color) override {
if (!rdr_) return;
SDL_SetRenderDrawColor(rdr_, color.r, color.g, color.b, color.a);
}
void fillRectF(const SDL_FRect* rect) override {
if (!rdr_ || !rect) return;
SDL_RenderFillRect(rdr_, rect);
}
void drawRectF(const SDL_FRect* rect) override {
if (!rdr_ || !rect) return;
SDL_RenderRect(rdr_, rect);
}
void renderLine(float x1, float y1, float x2, float y2) override {
if (!rdr_) return;
SDL_RenderLine(rdr_, x1, y1, x2, y2);
}
void present() override {
if (!rdr_) return;
SDL_RenderPresent(rdr_);
}
private:
SDL_Renderer* rdr_ = nullptr;
};
// Factory helper
std::unique_ptr<Renderer> MakeSDLRenderer(SDL_Renderer* rdr) {
return std::make_unique<SDLRendererImpl>(rdr);
}
} // namespace renderer

View File

@ -0,0 +1,41 @@
#include "ResourceManager.h"
#include <future>
namespace resources {
ResourceManager::ResourceManager() = default;
ResourceManager::~ResourceManager() = default;
std::future<std::shared_ptr<void>> ResourceManager::loadAsync(const std::string& key, std::function<std::shared_ptr<void>(const std::string&)> loader)
{
// Quick check for existing cached resource
{
std::lock_guard<std::mutex> lk(mutex_);
auto it = cache_.find(key);
if (it != cache_.end()) {
// Return already-available resource (keep strong ref)
auto sp = it->second;
if (sp) {
return std::async(std::launch::deferred, [sp]() { return sp; });
}
}
}
// Launch async loader
return std::async(std::launch::async, [this, key, loader]() {
auto res = loader(key);
if (res) {
std::lock_guard<std::mutex> lk(mutex_);
cache_[key] = res; // store strong reference
}
return res;
});
}
void ResourceManager::put(const std::string& key, std::shared_ptr<void> resource)
{
std::lock_guard<std::mutex> lk(mutex_);
cache_[key] = resource; // store strong reference so callers using raw pointers stay valid
}
} // namespace resources

View File

@ -0,0 +1,43 @@
#pragma once
#include <string>
#include <memory>
#include <unordered_map>
#include <mutex>
#include <future>
#include <functional>
namespace resources {
class ResourceManager {
public:
ResourceManager();
~ResourceManager();
// Return cached resource if available and of the right type
template<typename T>
std::shared_ptr<T> get(const std::string& key)
{
std::lock_guard<std::mutex> lk(mutex_);
auto it = cache_.find(key);
if (it == cache_.end()) return nullptr;
auto sp = it->second;
if (!sp) { cache_.erase(it); return nullptr; }
return std::static_pointer_cast<T>(sp);
}
// Asynchronously load a resource using the provided loader function.
// The loader must return a shared_ptr to the concrete resource (boxed as void).
std::future<std::shared_ptr<void>> loadAsync(const std::string& key, std::function<std::shared_ptr<void>(const std::string&)> loader);
// Insert a resource into the cache (thread-safe)
void put(const std::string& key, std::shared_ptr<void> resource);
private:
// Keep strong ownership of cached resources so they remain valid
// while present in the cache.
std::unordered_map<std::string, std::shared_ptr<void>> cache_;
std::mutex mutex_;
};
} // namespace resources

View File

@ -1,12 +1,14 @@
#include "MenuState.h" #include "MenuState.h"
#include "persistence/Scores.h" #include "persistence/Scores.h"
#include "../network/supabase_client.h" #include "../network/supabase_client.h"
#include "../network/NetSession.h"
#include "graphics/Font.h" #include "graphics/Font.h"
#include "../graphics/ui/HelpOverlay.h" #include "../graphics/ui/HelpOverlay.h"
#include "../core/GlobalState.h" #include "../core/GlobalState.h"
#include "../core/Settings.h" #include "../core/Settings.h"
#include "../core/state/StateManager.h" #include "../core/state/StateManager.h"
#include "../audio/Audio.h" #include "../audio/Audio.h"
#include "../audio/AudioManager.h"
#include "../audio/SoundEffect.h" #include "../audio/SoundEffect.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3/SDL_render.h> #include <SDL3/SDL_render.h>
@ -16,6 +18,7 @@
#include <array> #include <array>
#include <cmath> #include <cmath>
#include <vector> #include <vector>
#include <random>
// Use dynamic logical dimensions from GlobalState instead of hardcoded values // Use dynamic logical dimensions from GlobalState instead of hardcoded values
// This allows the UI to adapt when the window is resized or goes fullscreen // This allows the UI to adapt when the window is resized or goes fullscreen
@ -141,6 +144,17 @@ void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
coopSetupAnimating = true; coopSetupAnimating = true;
coopSetupDirection = 1; coopSetupDirection = 1;
coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0; coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0;
coopSetupStep = CoopSetupStep::ChoosePartner;
coopNetworkRoleSelected = 0;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
coopSetupRectsValid = false; coopSetupRectsValid = false;
selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate); selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate);
// Ensure the transition value is non-zero so render code can show // Ensure the transition value is non-zero so render code can show
@ -152,9 +166,22 @@ void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
coopSetupAnimating = true; coopSetupAnimating = true;
coopSetupDirection = -1; coopSetupDirection = -1;
coopSetupRectsValid = false; coopSetupRectsValid = false;
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
// Cancel any pending network session if the coop setup is being closed.
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopSetupStep = CoopSetupStep::ChoosePartner;
// Resume menu music only when requested (ESC should pass resumeMusic=false) // Resume menu music only when requested (ESC should pass resumeMusic=false)
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) { if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
Audio::instance().playMenuMusic(); if (auto sys = AudioManager::get()) sys->playMenuMusic();
} }
} }
} }
@ -280,58 +307,196 @@ void MenuState::onExit() {
} }
void MenuState::handleEvent(const SDL_Event& e) { void MenuState::handleEvent(const SDL_Event& e) {
// Text input for network IP entry (only while coop setup panel is active).
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_TEXT_INPUT) {
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
if (target.size() < 64) {
target += e.text.text;
}
return;
}
}
// Coop setup panel navigation (modal within the menu) // Coop setup panel navigation (modal within the menu)
// Handle this FIRST and consume key events so the main menu navigation doesn't interfere. // Handle this FIRST and consume key events so the main menu navigation doesn't interfere.
// Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat. // Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat.
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) { if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) {
// Coop setup panel navigation (modal within the menu)
switch (e.key.scancode) { switch (e.key.scancode) {
case SDL_SCANCODE_UP:
case SDL_SCANCODE_DOWN:
// Do NOT allow up/down to change anything while this panel is active
return;
case SDL_SCANCODE_ESCAPE:
// When in a nested network step, go back one step; otherwise close the panel.
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopSetupStep = CoopSetupStep::ChoosePartner;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
coopSetupStep = CoopSetupStep::NetworkChooseRole;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
return;
}
if (coopSetupStep == CoopSetupStep::NetworkWaiting) {
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopSetupStep = CoopSetupStep::NetworkChooseRole;
return;
}
showCoopSetupPanel(false, false);
return;
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_A: case SDL_SCANCODE_A:
coopSetupSelected = 0; if (coopSetupStep == CoopSetupStep::ChoosePartner) {
buttonFlash = 1.0; // 3-way selection: LOCAL / AI / NETWORK
coopSetupSelected = (coopSetupSelected + 3 - 1) % 3;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopNetworkRoleSelected = (coopNetworkRoleSelected + 2 - 1) % 2;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
return;
}
return; return;
case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_D: case SDL_SCANCODE_D:
coopSetupSelected = 1; if (coopSetupStep == CoopSetupStep::ChoosePartner) {
buttonFlash = 1.0; coopSetupSelected = (coopSetupSelected + 1) % 3;
return; buttonFlash = 1.0;
// Do NOT allow up/down to change anything return;
case SDL_SCANCODE_UP: }
case SDL_SCANCODE_DOWN: if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
return; coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2;
case SDL_SCANCODE_ESCAPE: buttonFlash = 1.0;
showCoopSetupPanel(false, false); return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
return;
}
return; return;
case SDL_SCANCODE_BACKSPACE:
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
if (!target.empty()) target.pop_back();
return;
}
break;
case SDL_SCANCODE_RETURN: case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_KP_ENTER:
case SDL_SCANCODE_SPACE: case SDL_SCANCODE_SPACE:
{ {
const bool useAI = (coopSetupSelected == 1); // Existing flows (Local 2P / AI) are preserved exactly.
if (ctx.coopVsAI) { if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) {
*ctx.coopVsAI = useAI; const bool useAI = (coopSetupSelected == 1);
} if (ctx.coopVsAI) {
if (ctx.game) { *ctx.coopVsAI = useAI;
ctx.game->setMode(GameMode::Cooperate); }
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); if (ctx.game) {
} ctx.game->setMode(GameMode::Cooperate);
if (ctx.coopGame) { ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); }
if (ctx.coopGame) {
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
// Close the panel without restarting menu music; gameplay will take over.
showCoopSetupPanel(false, false);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p",
coopSetupSelected,
ctx.startPlayTransition ? 1 : 0,
(void*)ctx.stateManager);
if (ctx.startPlayTransition) {
ctx.startPlayTransition();
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
}
return;
} }
// Close the panel without restarting menu music; gameplay will take over. // Network flow (new): choose host/join, confirm connection before starting.
showCoopSetupPanel(false, false); if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) {
coopSetupStep = CoopSetupStep::NetworkChooseRole;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager); coopNetworkRoleSelected = 0;
coopNetworkHandshakeSent = false;
if (ctx.startPlayTransition) { coopNetworkStatusText.clear();
ctx.startPlayTransition(); if (coopNetworkSession) {
} else if (ctx.stateManager) { coopNetworkSession->shutdown();
ctx.stateManager->setState(AppState::Playing); coopNetworkSession.reset();
}
buttonFlash = 1.0;
return;
} }
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
// First, let the user enter the address (bind for host, remote for join).
coopSetupStep = CoopSetupStep::NetworkEnterAddress;
coopNetworkStatusText.clear();
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StartTextInput(focusWin);
}
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopNetworkSession = std::make_unique<NetSession>();
const uint16_t port = coopNetworkPort;
bool ok = false;
if (coopNetworkRoleSelected == 0) {
const std::string bindIp = coopNetworkBindAddress;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST start bind=%s port=%u", bindIp.c_str(), (unsigned)port);
ok = coopNetworkSession->host(bindIp, port);
coopNetworkStatusText = ok ? "WAITING FOR PLAYER..." : ("HOST FAILED: " + coopNetworkSession->lastError());
} else {
const std::string joinIp = coopNetworkJoinAddress.empty() ? std::string("127.0.0.1") : coopNetworkJoinAddress;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] JOIN start remote=%s port=%u", joinIp.c_str(), (unsigned)port);
ok = coopNetworkSession->join(joinIp, port);
coopNetworkStatusText = ok ? "CONNECTING..." : ("JOIN FAILED: " + coopNetworkSession->lastError());
}
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
if (ok) {
coopSetupStep = CoopSetupStep::NetworkWaiting;
} else {
// Stay on role choice screen so user can back out.
coopNetworkSession.reset();
coopSetupStep = CoopSetupStep::NetworkChooseRole;
}
return;
}
// While waiting for connection, Enter does nothing.
return; return;
} }
default: default:
// Allow all other keys to be pressed, but don't let them affect the main menu while coop is open. // Allow other keys, but don't let them affect the main menu while coop is open.
return; return;
} }
} }
@ -796,6 +961,15 @@ void MenuState::handleEvent(const SDL_Event& e) {
} }
void MenuState::update(double frameMs) { void MenuState::update(double frameMs) {
// Transient network status message (e.g., disconnect) shown on return to menu.
if (ctx.coopNetUiStatusRemainingMs > 0.0) {
ctx.coopNetUiStatusRemainingMs -= frameMs;
if (ctx.coopNetUiStatusRemainingMs <= 0.0) {
ctx.coopNetUiStatusRemainingMs = 0.0;
ctx.coopNetUiStatusText.clear();
}
}
// Update logo animation counter // Update logo animation counter
GlobalState::instance().logoAnimCounter += frameMs; GlobalState::instance().logoAnimCounter += frameMs;
// Advance options panel animation if active // Advance options panel animation if active
@ -1056,14 +1230,27 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX; float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX;
useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255}); useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255});
scoresStartY += (float)tH + 12.0f; scoresStartY += (float)tH + 12.0f;
if (!ctx.coopNetUiStatusText.empty() && ctx.coopNetUiStatusRemainingMs > 0.0) {
float msgScale = 0.75f;
int mW = 0, mH = 0;
useFont->measure(ctx.coopNetUiStatusText, msgScale, mW, mH);
float msgX = (LOGICAL_W - (float)mW) * 0.5f + contentOffsetX;
useFont->draw(renderer, msgX, scoresStartY, ctx.coopNetUiStatusText, msgScale, SDL_Color{255, 224, 130, 255});
scoresStartY += (float)mH + 10.0f;
}
} }
static const std::vector<ScoreEntry> EMPTY_SCORES; static const std::vector<ScoreEntry> EMPTY_SCORES;
const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES; const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES;
// Choose which game_type to show based on current menu selection // Choose which game_type to show based on current menu selection or mouse hover.
// Prefer `hoveredButton` (mouse-over) when available so the TOP PLAYER panel
// updates responsively while the user moves the pointer over the bottom menu.
int activeBtn = (ctx.hoveredButton ? *ctx.hoveredButton : -1);
if (activeBtn < 0) activeBtn = selectedButton;
std::string wantedType = "classic"; std::string wantedType = "classic";
if (selectedButton == 0) wantedType = "classic"; // Play / Endless if (activeBtn == 0) wantedType = "classic"; // Play / Endless
else if (selectedButton == 1) wantedType = "cooperate"; // Coop else if (activeBtn == 1) wantedType = "cooperate"; // Coop
else if (selectedButton == 2) wantedType = "challenge"; // Challenge else if (activeBtn == 2) wantedType = "challenge"; // Challenge
// Filter highscores to the desired game type // Filter highscores to the desired game type
std::vector<ScoreEntry> filtered; std::vector<ScoreEntry> filtered;
filtered.reserve(hs.size()); filtered.reserve(hs.size());
@ -1358,18 +1545,20 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
// highscores area (not sliding offscreen with the scores). // highscores area (not sliding offscreen with the scores).
const float panelBaseY = scoresStartY - 20.0f; const float panelBaseY = scoresStartY - 20.0f;
// Make the choice buttons smaller, add more spacing, and raise them higher // Choice buttons (partner selection) and nested network host/join UI
const float btnW2 = std::min(300.0f, panelW * 0.30f);
const float btnH2 = 60.0f; const float btnH2 = 60.0f;
const float gap = 96.0f; const float gap = 34.0f;
// Shift the image and buttons to the right for layout balance (reduced) const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f);
const float shiftX = 20.0f; // move right by 30px (moved 20px left from previous) const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f;
const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f + shiftX; // Shift the image and buttons slightly for layout balance
const float shiftX = 20.0f;
const float bx = panelBaseX + (panelW - totalChoiceW) * 0.5f + shiftX;
// Move the buttons up by ~80px to sit closer under the logo // Move the buttons up by ~80px to sit closer under the logo
const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f; const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f;
coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 }; coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 };
coopSetupBtnRects[1] = SDL_FRect{ bx + btnW2 + gap, by, btnW2, btnH2 }; coopSetupBtnRects[1] = SDL_FRect{ bx + (btnW2 + gap), by, btnW2, btnH2 };
coopSetupBtnRects[2] = SDL_FRect{ bx + (btnW2 + gap) * 2.0f, by, btnW2, btnH2 };
coopSetupRectsValid = true; coopSetupRectsValid = true;
SDL_Color bg{ 24, 36, 52, 220 }; SDL_Color bg{ 24, 36, 52, 220 };
@ -1392,21 +1581,24 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
} }
} }
// If the image loaded, render it centered above the two choice buttons // If the image loaded, render it centered above the choice buttons
// Compute fade alpha from the coop transition so it can be used for image, text and buttons // Compute fade alpha from the coop transition so it can be used for image, text and buttons
float alphaFactor = static_cast<float>(coopSetupTransition); float alphaFactor = static_cast<float>(coopSetupTransition);
if (alphaFactor < 0.0f) alphaFactor = 0.0f; if (alphaFactor < 0.0f) alphaFactor = 0.0f;
if (alphaFactor > 1.0f) alphaFactor = 1.0f; if (alphaFactor > 1.0f) alphaFactor = 1.0f;
// Compute coop info image placement (draw as background for both ChoosePartner and Network steps)
float imgX = 0.0f, imgY = 0.0f, targetW = 0.0f, targetH = 0.0f;
bool hasCoopImg = false;
if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) { if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) {
float totalW = btnW2 * 2.0f + gap; float totalW = totalChoiceW;
// Increase allowed image width by ~15% (was 0.75 of totalW) // Keep coop info image slightly smaller than the button row.
const float scaleFactor = 0.75f * 1.25f; // ~0.8625 // Use a modest scale so it doesn't dominate the UI.
float maxImgW = totalW * scaleFactor; float maxImgW = totalW * 0.65f;
float targetW = std::min(maxImgW, static_cast<float>(coopInfoTexW)); targetW = std::min(maxImgW, static_cast<float>(coopInfoTexW));
float scale = targetW / static_cast<float>(coopInfoTexW); float scale = targetW / static_cast<float>(coopInfoTexW);
float targetH = static_cast<float>(coopInfoTexH) * scale; targetH = static_cast<float>(coopInfoTexH) * scale;
float imgX = bx + (totalW - targetW) * 0.5f; imgX = bx + (totalW - targetW) * 0.5f;
float imgY = by - targetH - 8.0f; // keep the small gap above buttons imgY = by - targetH - 8.0f; // keep the small gap above buttons
float minY = panelBaseY + 6.0f; float minY = panelBaseY + 6.0f;
if (imgY < minY) imgY = minY; if (imgY < minY) imgY = minY;
SDL_FRect dst{ imgX, imgY, targetW, targetH }; SDL_FRect dst{ imgX, imgY, targetW, targetH };
@ -1414,28 +1606,30 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
// Make the coop info image slightly transparent scaled by transition // Make the coop info image slightly transparent scaled by transition
SDL_SetTextureAlphaMod(coopInfoTexture, static_cast<Uint8>(std::round(200.0f * alphaFactor))); SDL_SetTextureAlphaMod(coopInfoTexture, static_cast<Uint8>(std::round(200.0f * alphaFactor)));
SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst); SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst);
hasCoopImg = true;
// Draw cooperative instructions inside the panel area (overlayed on the panel background) // Only draw the instructional overlay text when choosing partner.
FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; if (coopSetupStep == CoopSetupStep::ChoosePartner) {
if (f) { FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont;
const float pad = 38.0f; if (f) {
float textX = panelBaseX + pad; const float pad = 38.0f;
// Position the text over the lower portion of the image (overlay) float textX = panelBaseX + pad;
// Move the block upward by ~150px to match UI request // Position the text over the lower portion of the image (overlay)
float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f; // Move the block upward by ~150px to match UI request
float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f;
// Bulleted list (measure sample line height first) // Bulleted list (measure sample line height first)
const std::vector<std::string> bullets = { const std::vector<std::string> bullets = {
"The playfield is shared between two players", "The playfield is shared between two players",
"Each player controls one half of the grid", "Each player controls one half of the grid",
"A line clears only when both halves are filled", "A line clears only when both halves are filled",
"Timing and coordination are essential" "Timing and coordination are essential"
}; };
float bulletScale = 0.78f; float bulletScale = 0.78f;
SDL_Color bulletCol{200,220,230,220}; SDL_Color bulletCol{200,220,230,220};
bulletCol.a = static_cast<Uint8>(std::round(bulletCol.a * alphaFactor)); bulletCol.a = static_cast<Uint8>(std::round(bulletCol.a * alphaFactor));
int sampleLW = 0, sampleLH = 0; int sampleLW = 0, sampleLH = 0;
f->measure(bullets[0], bulletScale, sampleLW, sampleLH); f->measure(bullets[0], bulletScale, sampleLW, sampleLH);
// Header: move it up by one sample row so it sits higher // Header: move it up by one sample row so it sits higher
const std::string header = "* HOW TO PLAY COOPERATE MODE *"; const std::string header = "* HOW TO PLAY COOPERATE MODE *";
@ -1470,6 +1664,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
f->draw(renderer, goalX, textY, goalText, 0.86f, goalTextCol); f->draw(renderer, goalX, textY, goalText, 0.86f, goalTextCol);
} }
} }
}
// Delay + eased fade specifically for the two coop buttons so they appear after the image/text. // Delay + eased fade specifically for the two coop buttons so they appear after the image/text.
const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading
@ -1479,10 +1674,134 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
float buttonFade = rawBtn * rawBtn; float buttonFade = rawBtn * rawBtn;
SDL_Color bgA = bg; bgA.a = static_cast<Uint8>(std::round(bgA.a * buttonFade)); SDL_Color bgA = bg; bgA.a = static_cast<Uint8>(std::round(bgA.a * buttonFade));
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(std::round(borderA.a * buttonFade)); SDL_Color borderA = border; borderA.a = static_cast<Uint8>(std::round(borderA.a * buttonFade));
UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[0].x + btnW2 * 0.5f, coopSetupBtnRects[0].y + btnH2 * 0.5f,
btnW2, btnH2, "2 PLAYERS", false, coopSetupSelected == 0, bgA, borderA, false, nullptr); // Step 1: choose partner mode
UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[1].x + btnW2 * 0.5f, coopSetupBtnRects[1].y + btnH2 * 0.5f, if (coopSetupStep == CoopSetupStep::ChoosePartner) {
btnW2, btnH2, "COMPUTER (AI)", false, coopSetupSelected == 1, bgA, borderA, false, nullptr); UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[0].x + btnW2 * 0.5f,
coopSetupBtnRects[0].y + btnH2 * 0.5f,
btnW2, btnH2,
"LOCAL CO-OP",
false,
coopSetupSelected == 0,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[1].x + btnW2 * 0.5f,
coopSetupBtnRects[1].y + btnH2 * 0.5f,
btnW2, btnH2,
"AI PARTNER",
false,
coopSetupSelected == 1,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[2].x + btnW2 * 0.5f,
coopSetupBtnRects[2].y + btnH2 * 0.5f,
btnW2, btnH2,
"2 PLAYER (NET)",
false,
coopSetupSelected == 2,
bgA,
borderA,
false,
nullptr);
}
// Step 2: network host/join selection and address entry
if (coopSetupStep == CoopSetupStep::NetworkChooseRole || coopSetupStep == CoopSetupStep::NetworkEnterAddress || coopSetupStep == CoopSetupStep::NetworkWaiting) {
// Draw two buttons centered under the main row.
const float roleBtnW = std::min(280.0f, panelW * 0.30f);
const float roleGap = 48.0f;
const float roleTotalW = roleBtnW * 2.0f + roleGap;
const float roleX = panelBaseX + (panelW - roleTotalW) * 0.5f + shiftX;
// Move the host/join buttons down from the previous higher position.
// Shift down by one button height plus half a button (effectively lower them):
const float roleY = by + (btnH2 * 0.5f) - 18.0f;
SDL_FRect hostRect{ roleX, roleY, roleBtnW, btnH2 };
SDL_FRect joinRect{ roleX + roleBtnW + roleGap, roleY, roleBtnW, btnH2 };
UIRenderer::drawButton(renderer, ctx.pixelFont,
hostRect.x + roleBtnW * 0.5f,
hostRect.y + btnH2 * 0.5f,
roleBtnW,
btnH2,
"HOST GAME",
false,
coopNetworkRoleSelected == 0,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
joinRect.x + roleBtnW * 0.5f,
joinRect.y + btnH2 * 0.5f,
roleBtnW,
btnH2,
"JOIN GAME",
false,
coopNetworkRoleSelected == 1,
bgA,
borderA,
false,
nullptr);
FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont;
if (f) {
SDL_Color infoCol{200, 220, 230, static_cast<Uint8>(std::round(220.0f * buttonFade))};
// Draw connection info on separate lines and shift right by ~200px
char portLine[64];
std::snprintf(portLine, sizeof(portLine), "PORT %u", (unsigned)coopNetworkPort);
char hostLine[128];
std::snprintf(hostLine, sizeof(hostLine), "HOST IP %s", coopNetworkBindAddress.c_str());
char joinLine[128];
std::snprintf(joinLine, sizeof(joinLine), "JOIN IP %s", coopNetworkJoinAddress.c_str());
const float textShiftX = 200.0f;
const float textX = panelBaseX + 60.0f + textShiftX;
const float endpointY = (hasCoopImg ? (imgY + targetH * 0.62f) : (roleY + btnH2 + 12.0f));
const float lineSpacing = 28.0f;
// Show only the minimal info needed for the selected role.
f->draw(renderer, textX, endpointY, portLine, 0.90f, infoCol);
if (coopNetworkRoleSelected == 0) {
// Host: show bind address only
f->draw(renderer, textX, endpointY + lineSpacing, hostLine, 0.90f, infoCol);
} else {
// Client: show join target only
f->draw(renderer, textX, endpointY + lineSpacing, joinLine, 0.90f, infoCol);
}
float hintY = endpointY + lineSpacing * 2.0f + 6.0f;
// Bottom helper prompt: show a compact instruction under the image window
float bottomY = hasCoopImg ? (imgY + targetH + 18.0f) : (hintY + 36.0f);
SDL_Color bottomCol{180,200,210,200};
if (coopNetworkRoleSelected == 0) {
f->draw(renderer, textX, bottomY, "HOST: press ENTER to edit bind IP, then press ENTER to confirm", 0.82f, bottomCol);
} else {
f->draw(renderer, textX, bottomY, "JOIN: press ENTER to type server IP, then press ENTER to connect", 0.82f, bottomCol);
}
if (coopSetupStep == CoopSetupStep::NetworkWaiting && !coopNetworkStatusText.empty()) {
SDL_Color statusCol{255, 215, 80, static_cast<Uint8>(std::round(240.0f * buttonFade))};
f->draw(renderer, textX, hintY, coopNetworkStatusText, 1.00f, statusCol);
} else if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
const char* label = (coopNetworkRoleSelected == 0) ? "TYPE HOST IP (BIND) THEN ENTER" : "TYPE JOIN IP THEN ENTER";
f->draw(renderer, textX, hintY, label, 0.82f, hintCol);
} else {
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
f->draw(renderer, textX, hintY, "PRESS ENTER TO EDIT/CONFIRM ESC TO GO BACK", 0.82f, hintCol);
}
}
}
} }
// NOTE: slide-up COOP panel intentionally removed. Only the inline // NOTE: slide-up COOP panel intentionally removed. Only the inline
// highscores-area choice buttons are shown when coop setup is active. // highscores-area choice buttons are shown when coop setup is active.
@ -1840,6 +2159,108 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
{ {
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); } FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); }
} }
// Network coop flow polling (non-blocking)
if (coopSetupAnimating || coopSetupVisible) {
if (coopSetupStep == CoopSetupStep::NetworkWaiting && coopNetworkSession) {
coopNetworkSession->poll(0);
// Update status depending on connection and role.
if (!coopNetworkSession->isConnected()) {
// Keep existing text (WAITING/CONNECTING) unless an error occurs.
} else {
// Host sends handshake after peer connects.
if (coopNetworkRoleSelected == 0 && !coopNetworkHandshakeSent) {
std::random_device rd;
uint32_t seed = static_cast<uint32_t>(rd());
if (seed == 0u) seed = 1u;
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
NetSession::Handshake hs{ seed, 0u, startLevel };
if (coopNetworkSession->sendHandshake(hs)) {
coopNetworkHandshakeSent = true;
ctx.coopNetRngSeed = seed;
coopNetworkStatusText = "CONNECTED";
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST handshake sent seed=%u level=%u", seed, (unsigned)startLevel);
} else {
coopNetworkStatusText = "HANDSHAKE FAILED";
}
}
// Client waits for handshake.
if (coopNetworkRoleSelected == 1) {
auto hs = coopNetworkSession->takeReceivedHandshake();
if (hs.has_value()) {
coopNetworkStatusText = "CONNECTED";
coopNetworkHandshakeSent = true;
ctx.coopNetRngSeed = hs->rngSeed;
if (ctx.startLevelSelection) {
*ctx.startLevelSelection = static_cast<int>(hs->startLevel);
}
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] CLIENT handshake recv seed=%u level=%u", hs->rngSeed, (unsigned)hs->startLevel);
} else {
coopNetworkStatusText = "CONNECTED - WAITING FOR HOST...";
}
}
// Confirmed connection => start COOPERATE (network) session.
// Note: gameplay/network input injection is implemented separately.
if (coopNetworkHandshakeSent) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP] START gameplay (role=%s localIsLeft=%d seed=%u level=%u)",
(coopNetworkRoleSelected == 0 ? "HOST" : "CLIENT"),
(coopNetworkRoleSelected == 0 ? 1 : 0),
(unsigned)ctx.coopNetRngSeed,
(unsigned)(ctx.startLevelSelection ? *ctx.startLevelSelection : 0));
// Hand off the session to gameplay.
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = true;
ctx.coopNetIsHost = (coopNetworkRoleSelected == 0);
ctx.coopNetLocalIsLeft = (coopNetworkRoleSelected == 0);
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
ctx.coopNetDesyncDetected = false;
const uint32_t seed = (ctx.coopNetRngSeed == 0u) ? 1u : ctx.coopNetRngSeed;
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
if (ctx.coopVsAI) {
*ctx.coopVsAI = false;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
// Deterministic reset for network coop.
ctx.coopGame->resetDeterministic(startLevel, seed);
}
// Transfer ownership of the active session.
ctx.coopNetSession = std::move(coopNetworkSession);
// Close the panel without restarting menu music; gameplay will take over.
showCoopSetupPanel(false, false);
// For network lockstep, do NOT run the menu->play countdown/fade.
// Any local countdown introduces drift and stalls.
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
if (ctx.game) ctx.game->setPaused(false);
if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
} else if (ctx.startPlayTransition) {
// Fallback if state manager is unavailable.
ctx.startPlayTransition();
}
}
}
}
}
} }

View File

@ -2,6 +2,12 @@
#pragma once #pragma once
#include "State.h" #include "State.h"
#include <cstdint>
#include <memory>
#include <string>
class NetSession;
class MenuState : public State { class MenuState : public State {
public: public:
MenuState(StateContext& ctx); MenuState(StateContext& ctx);
@ -105,8 +111,27 @@ private:
double coopSetupTransition = 0.0; // 0..1 double coopSetupTransition = 0.0; // 0..1
double coopSetupTransitionDurationMs = 320.0; double coopSetupTransitionDurationMs = 320.0;
int coopSetupDirection = 1; // 1 show, -1 hide int coopSetupDirection = 1; // 1 show, -1 hide
int coopSetupSelected = 0; // 0 = 2 players, 1 = AI // 0 = Local co-op (2 players), 1 = AI partner, 2 = 2 player (network)
SDL_FRect coopSetupBtnRects[2]{}; int coopSetupSelected = 0;
enum class CoopSetupStep {
ChoosePartner,
NetworkChooseRole,
NetworkEnterAddress,
NetworkWaiting,
};
CoopSetupStep coopSetupStep = CoopSetupStep::ChoosePartner;
// Network sub-flow state (only used when coopSetupSelected == 2)
int coopNetworkRoleSelected = 0; // 0 = host, 1 = join
std::string coopNetworkBindAddress = "0.0.0.0";
std::string coopNetworkJoinAddress = "127.0.0.1";
uint16_t coopNetworkPort = 7777;
bool coopNetworkHandshakeSent = false;
std::string coopNetworkStatusText;
std::unique_ptr<NetSession> coopNetworkSession;
SDL_FRect coopSetupBtnRects[3]{};
bool coopSetupRectsValid = false; bool coopSetupRectsValid = false;
// Optional cooperative info image shown when coop setup panel is active // Optional cooperative info image shown when coop setup panel is active
SDL_Texture* coopInfoTexture = nullptr; SDL_Texture* coopInfoTexture = nullptr;

View File

@ -2,6 +2,7 @@
#include "../core/state/StateManager.h" #include "../core/state/StateManager.h"
#include "../graphics/ui/Font.h" #include "../graphics/ui/Font.h"
#include "../audio/Audio.h" #include "../audio/Audio.h"
#include "../audio/AudioManager.h"
#include "../audio/SoundEffect.h" #include "../audio/SoundEffect.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
@ -220,7 +221,7 @@ void OptionsState::toggleFullscreen() {
} }
void OptionsState::toggleMusic() { void OptionsState::toggleMusic() {
Audio::instance().toggleMute(); if (auto sys = AudioManager::get()) sys->toggleMute();
// If muted, music is disabled. If not muted, music is enabled. // If muted, music is disabled. If not muted, music is enabled.
// Note: Audio::instance().isMuted() returns true if muted. // Note: Audio::instance().isMuted() returns true if muted.
// But Audio class doesn't expose isMuted directly in header usually? // But Audio class doesn't expose isMuted directly in header usually?

View File

@ -6,9 +6,11 @@
#include "../persistence/Scores.h" #include "../persistence/Scores.h"
#include "../audio/Audio.h" #include "../audio/Audio.h"
#include "../audio/SoundEffect.h" #include "../audio/SoundEffect.h"
#include "../graphics/Font.h"
#include "../graphics/renderers/GameRenderer.h" #include "../graphics/renderers/GameRenderer.h"
#include "../core/Settings.h" #include "../core/Settings.h"
#include "../core/Config.h" #include "../core/Config.h"
#include "../network/CoopNetButtons.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
// File-scope transport/spawn detection state // File-scope transport/spawn detection state
@ -24,9 +26,17 @@ void PlayingState::onEnter() {
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) { if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
if (ctx.startLevelSelection) { if (ctx.startLevelSelection) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
ctx.game->reset(*ctx.startLevelSelection); const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession;
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
ctx.coopGame->reset(*ctx.startLevelSelection); // For network co-op, MenuState already performed a deterministic reset using the negotiated seed.
// Re-resetting here would overwrite it (and will desync).
if (!coopNetActive) {
ctx.game->reset(*ctx.startLevelSelection);
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
ctx.coopGame->reset(*ctx.startLevelSelection);
}
} else {
ctx.game->setPaused(false);
} }
} }
} else { } else {
@ -46,6 +56,18 @@ void PlayingState::onExit() {
SDL_DestroyTexture(m_renderTarget); SDL_DestroyTexture(m_renderTarget);
m_renderTarget = nullptr; m_renderTarget = nullptr;
} }
// If we are leaving gameplay during network co-op, tear down the session so
// hosting/joining again works without restarting the app.
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = false;
ctx.coopNetStalled = false;
ctx.coopNetDesyncDetected = false;
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
} }
void PlayingState::handleEvent(const SDL_Event& e) { void PlayingState::handleEvent(const SDL_Event& e) {
@ -135,6 +157,10 @@ void PlayingState::handleEvent(const SDL_Event& e) {
// Pause toggle (P) - matches classic behavior; disabled during countdown // Pause toggle (P) - matches classic behavior; disabled during countdown
if (e.key.scancode == SDL_SCANCODE_P) { if (e.key.scancode == SDL_SCANCODE_P) {
// Network co-op uses lockstep; local pause would desync/stall the peer.
if (ctx.coopNetEnabled && ctx.coopNetSession) {
return;
}
const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) || const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) ||
(ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed); (ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed);
if (!countdown) { if (!countdown) {
@ -149,6 +175,49 @@ void PlayingState::handleEvent(const SDL_Event& e) {
} }
if (coopActive && ctx.coopGame) { if (coopActive && ctx.coopGame) {
// Network co-op: route one-shot actions into a pending bitmask for lockstep.
if (ctx.coopNetEnabled && ctx.coopNetSession) {
const bool localIsLeft = ctx.coopNetLocalIsLeft;
const SDL_Scancode sc = e.key.scancode;
if (localIsLeft) {
if (sc == SDL_SCANCODE_W) {
ctx.coopNetPendingButtons |= coopnet::RotCW;
return;
}
if (sc == SDL_SCANCODE_Q) {
ctx.coopNetPendingButtons |= coopnet::RotCCW;
return;
}
if (sc == SDL_SCANCODE_LSHIFT || sc == SDL_SCANCODE_E) {
ctx.coopNetPendingButtons |= coopnet::HardDrop;
return;
}
if (sc == SDL_SCANCODE_LCTRL) {
ctx.coopNetPendingButtons |= coopnet::Hold;
return;
}
} else {
if (sc == SDL_SCANCODE_UP) {
const bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.coopNetPendingButtons |= upIsCW ? coopnet::RotCW : coopnet::RotCCW;
return;
}
if (sc == SDL_SCANCODE_RALT) {
ctx.coopNetPendingButtons |= coopnet::RotCCW;
return;
}
if (sc == SDL_SCANCODE_SPACE || sc == SDL_SCANCODE_RSHIFT) {
ctx.coopNetPendingButtons |= coopnet::HardDrop;
return;
}
if (sc == SDL_SCANCODE_RCTRL) {
ctx.coopNetPendingButtons |= coopnet::Hold;
return;
}
}
// If coopNet is active, suppress local co-op direct action keys.
}
const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI); const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
// Player 1 (left): when AI is enabled it controls the left side so // Player 1 (left): when AI is enabled it controls the left side so
@ -313,6 +382,31 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
// But countdown should definitely NOT show the "PAUSED" overlay. // But countdown should definitely NOT show the "PAUSED" overlay.
bool shouldBlur = paused && !countdown && !challengeClearFx; bool shouldBlur = paused && !countdown && !challengeClearFx;
auto renderNetOverlay = [&]() {
if (!coopActive || !ctx.coopNetEnabled || !ctx.pixelFont) return;
if (!ctx.coopNetDesyncDetected && !ctx.coopNetStalled) return;
const char* text = ctx.coopNetDesyncDetected ? "NET: DESYNC" : "NET: STALLED";
SDL_Color textColor = ctx.coopNetDesyncDetected ? SDL_Color{255, 230, 180, 255} : SDL_Color{255, 224, 130, 255};
float scale = 0.75f;
int tw = 0, th = 0;
ctx.pixelFont->measure(text, scale, tw, th);
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const float pad = 8.0f;
const float x = 18.0f;
const float y = 14.0f;
SDL_FRect bg{ x - pad, y - pad, (float)tw + pad * 2.0f, (float)th + pad * 2.0f };
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
SDL_RenderFillRect(renderer, &bg);
ctx.pixelFont->draw(renderer, x, y, text, scale, textColor);
SDL_SetRenderDrawBlendMode(renderer, prevBlend);
};
if (shouldBlur && m_renderTarget) { if (shouldBlur && m_renderTarget) {
// Render game to texture // Render game to texture
SDL_SetRenderTarget(renderer, m_renderTarget); SDL_SetRenderTarget(renderer, m_renderTarget);
@ -421,6 +515,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
SDL_SetRenderViewport(renderer, &oldVP); SDL_SetRenderViewport(renderer, &oldVP);
SDL_SetRenderScale(renderer, oldSX, oldSY); SDL_SetRenderScale(renderer, oldSX, oldSY);
// Net overlay (on top of blurred game, under pause/exit overlays)
renderNetOverlay();
// Draw overlays // Draw overlays
if (exitPopup) { if (exitPopup) {
GameRenderer::renderExitPopup( GameRenderer::renderExitPopup(
@ -466,6 +563,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
(float)winW, (float)winW,
(float)winH (float)winH
); );
// Net overlay (on top of coop HUD)
renderNetOverlay();
} else { } else {
GameRenderer::renderPlayingState( GameRenderer::renderPlayingState(
renderer, renderer,

View File

@ -6,6 +6,9 @@
#include <functional> #include <functional>
#include <string> #include <string>
#include <array> #include <array>
#include <cstdint>
#include "../network/NetSession.h"
// Forward declarations for frequently used types // Forward declarations for frequently used types
class Game; class Game;
@ -81,12 +84,31 @@ struct StateContext {
std::string* playerName = nullptr; // Shared player name buffer for highscores/options std::string* playerName = nullptr; // Shared player name buffer for highscores/options
// Coop setting: when true, COOPERATE runs with a computer-controlled right player. // Coop setting: when true, COOPERATE runs with a computer-controlled right player.
bool* coopVsAI = nullptr; bool* coopVsAI = nullptr;
// COOPERATE (network) --------------------------------------------------
// These fields are only meaningful when `coopNetEnabled` is true.
bool coopNetEnabled = false;
bool coopNetIsHost = false;
bool coopNetLocalIsLeft = true; // host = left (WASD), client = right (arrows)
uint32_t coopNetRngSeed = 0;
uint32_t coopNetTick = 0;
uint8_t coopNetPendingButtons = 0; // one-shot actions captured from keydown (rotate/hold/harddrop)
bool coopNetStalled = false; // true when waiting for remote input for current tick
bool coopNetDesyncDetected = false;
std::string coopNetUiStatusText; // transient status shown in menu after net abort
double coopNetUiStatusRemainingMs = 0.0;
std::unique_ptr<NetSession> coopNetSession;
bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable
std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully
std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level) std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level)
// Startup transition fade (used for intro video -> main).
// When active, the app should render a black overlay with alpha = startupFadeAlpha*255.
bool* startupFadeActive = nullptr;
float* startupFadeAlpha = nullptr;
// Pointer to the application's StateManager so states can request transitions // Pointer to the application's StateManager so states can request transitions
StateManager* stateManager = nullptr; StateManager* stateManager = nullptr;
// Optional explicit per-button coordinates (logical coordinates). When // Optional explicit per-button coordinates (logical coordinates). When

390
src/states/VideoState.cpp Normal file
View File

@ -0,0 +1,390 @@
// VideoState.cpp
#include "VideoState.h"
#include "../video/VideoPlayer.h"
#include "../audio/Audio.h"
#include "../audio/AudioManager.h"
#include "../core/state/StateManager.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <cstdint>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
VideoState::VideoState(StateContext& ctx)
: State(ctx)
, m_player(std::make_unique<VideoPlayer>())
{
}
VideoState::~VideoState() {
onExit();
}
bool VideoState::begin(SDL_Renderer* renderer, const std::string& path) {
m_path = path;
if (!m_player) {
m_player = std::make_unique<VideoPlayer>();
}
if (!m_player->open(m_path, renderer)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to open intro video: %s", m_path.c_str());
return false;
}
if (!m_player->decodeFirstFrame()) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to decode first frame: %s", m_path.c_str());
// Still allow entering; we will likely render black.
}
return true;
}
void VideoState::onEnter() {
m_phase = Phase::FadeInFirstFrame;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 1.0f;
m_audioDecoded.store(false);
m_audioDecodeFailed.store(false);
m_audioStarted = false;
m_audioPcm.clear();
m_audioRate = 44100;
m_audioChannels = 2;
// Decode audio in the background during fade-in.
m_audioThread = std::make_unique<std::jthread>([this](std::stop_token st) {
(void)st;
std::vector<int16_t> pcm;
int rate = 44100;
int channels = 2;
const bool ok = decodeAudioPcm16Stereo44100(m_path, pcm, rate, channels);
if (!ok) {
m_audioDecodeFailed.store(true);
m_audioDecoded.store(true, std::memory_order_release);
return;
}
// Transfer results.
m_audioRate = rate;
m_audioChannels = channels;
m_audioPcm = std::move(pcm);
m_audioDecoded.store(true, std::memory_order_release);
});
}
void VideoState::onExit() {
stopAudio();
if (m_audioThread) {
// Request stop and join.
m_audioThread.reset();
}
}
void VideoState::handleEvent(const SDL_Event& e) {
(void)e;
}
void VideoState::startAudioIfReady() {
if (m_audioStarted) return;
if (!m_audioDecoded.load(std::memory_order_acquire)) return;
if (m_audioDecodeFailed.load()) return;
if (m_audioPcm.empty()) return;
// Use the existing audio output path (same device as music/SFX).
if (auto sys = AudioManager::get()) sys->playSfx(m_audioPcm, m_audioChannels, m_audioRate, 1.0f);
m_audioStarted = true;
}
void VideoState::stopAudio() {
// We currently feed intro audio as an SFX buffer into the mixer.
// It will naturally end; no explicit stop is required.
}
void VideoState::update(double frameMs) {
switch (m_phase) {
case Phase::FadeInFirstFrame: {
m_phaseClockMs += frameMs;
const float t = (FADE_IN_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_IN_MS, 0.0, 1.0)) : 1.0f;
m_blackOverlayAlpha = 1.0f - t;
if (t >= 1.0f) {
m_phase = Phase::Playing;
m_phaseClockMs = 0.0;
if (m_player) {
m_player->start();
}
startAudioIfReady();
}
break;
}
case Phase::Playing: {
startAudioIfReady();
if (m_player) {
m_player->update(frameMs);
if (m_player->isFinished()) {
m_phase = Phase::FadeOutToBlack;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 0.0f;
}
} else {
m_phase = Phase::FadeOutToBlack;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 0.0f;
}
break;
}
case Phase::FadeOutToBlack: {
m_phaseClockMs += frameMs;
const float t = (FADE_OUT_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_OUT_MS, 0.0, 1.0)) : 1.0f;
m_blackOverlayAlpha = t;
if (t >= 1.0f) {
// Switch to MAIN (Menu) with a fade-in from black.
if (ctx.startupFadeAlpha) {
*ctx.startupFadeAlpha = 1.0f;
}
if (ctx.startupFadeActive) {
*ctx.startupFadeActive = true;
}
if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Menu);
}
m_phase = Phase::Done;
}
break;
}
case Phase::Done:
default:
break;
}
}
void VideoState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
(void)logicalScale;
(void)logicalVP;
if (!renderer) return;
int winW = 0, winH = 0;
SDL_GetRenderOutputSize(renderer, &winW, &winH);
// Draw video fullscreen if available.
if (m_player && m_player->isTextureReady()) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
m_player->render(renderer, winW, winH);
} else {
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect r{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &r);
}
// Apply fade overlay (black).
if (m_blackOverlayAlpha > 0.0f) {
const Uint8 a = (Uint8)std::clamp((int)std::lround(m_blackOverlayAlpha * 255.0f), 0, 255);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &full);
}
}
bool VideoState::decodeAudioPcm16Stereo44100(
const std::string& path,
std::vector<int16_t>& outPcm,
int& outRate,
int& outChannels
) {
outPcm.clear();
outRate = 44100;
outChannels = 2;
AVFormatContext* fmt = nullptr;
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) {
return false;
}
if (avformat_find_stream_info(fmt, nullptr) < 0) {
avformat_close_input(&fmt);
return false;
}
int audioStream = -1;
for (unsigned i = 0; i < fmt->nb_streams; ++i) {
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
audioStream = (int)i;
break;
}
}
if (audioStream < 0) {
avformat_close_input(&fmt);
return false;
}
AVCodecParameters* codecpar = fmt->streams[audioStream]->codecpar;
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
if (!codec) {
avformat_close_input(&fmt);
return false;
}
AVCodecContext* dec = avcodec_alloc_context3(codec);
if (!dec) {
avformat_close_input(&fmt);
return false;
}
if (avcodec_parameters_to_context(dec, codecpar) < 0) {
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
if (avcodec_open2(dec, codec, nullptr) < 0) {
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
AVChannelLayout outLayout{};
av_channel_layout_default(&outLayout, 2);
AVChannelLayout inLayout{};
if (av_channel_layout_copy(&inLayout, &dec->ch_layout) < 0 || inLayout.nb_channels <= 0) {
av_channel_layout_uninit(&inLayout);
av_channel_layout_default(&inLayout, 2);
}
SwrContext* swr = nullptr;
if (swr_alloc_set_opts2(
&swr,
&outLayout,
AV_SAMPLE_FMT_S16,
44100,
&inLayout,
dec->sample_fmt,
dec->sample_rate,
0,
nullptr
) < 0) {
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
if (swr_init(swr) < 0) {
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
AVPacket* pkt = av_packet_alloc();
AVFrame* frame = av_frame_alloc();
if (!pkt || !frame) {
if (pkt) av_packet_free(&pkt);
if (frame) av_frame_free(&frame);
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
const int outRateConst = 44100;
const int outCh = 2;
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index != audioStream) {
av_packet_unref(pkt);
continue;
}
if (avcodec_send_packet(dec, pkt) < 0) {
av_packet_unref(pkt);
continue;
}
av_packet_unref(pkt);
while (true) {
const int rr = avcodec_receive_frame(dec, frame);
if (rr == AVERROR(EAGAIN) || rr == AVERROR_EOF) {
break;
}
if (rr < 0) {
break;
}
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
std::vector<uint8_t> outBytes;
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
uint8_t* outData[1] = { outBytes.data() };
const uint8_t** inData = (const uint8_t**)frame->data;
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
if (converted > 0) {
const size_t samplesOut = (size_t)converted * (size_t)outCh;
const int16_t* asS16 = (const int16_t*)outBytes.data();
const size_t oldSize = outPcm.size();
outPcm.resize(oldSize + samplesOut);
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
}
av_frame_unref(frame);
}
}
// Flush decoder
avcodec_send_packet(dec, nullptr);
while (avcodec_receive_frame(dec, frame) >= 0) {
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
std::vector<uint8_t> outBytes;
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
uint8_t* outData[1] = { outBytes.data() };
const uint8_t** inData = (const uint8_t**)frame->data;
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
if (converted > 0) {
const size_t samplesOut = (size_t)converted * (size_t)outCh;
const int16_t* asS16 = (const int16_t*)outBytes.data();
const size_t oldSize = outPcm.size();
outPcm.resize(oldSize + samplesOut);
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
}
av_frame_unref(frame);
}
av_frame_free(&frame);
av_packet_free(&pkt);
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
outRate = outRateConst;
outChannels = outCh;
return !outPcm.empty();
}

67
src/states/VideoState.h Normal file
View File

@ -0,0 +1,67 @@
// VideoState.h
#pragma once
#include "State.h"
#include <atomic>
#include <memory>
#include <string>
#include <thread>
#include <vector>
class VideoPlayer;
class VideoState : public State {
public:
explicit VideoState(StateContext& ctx);
~VideoState() override;
void onEnter() override;
void onExit() override;
void handleEvent(const SDL_Event& e) override;
void update(double frameMs) override;
void render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) override;
// Called from the App's on-enter hook so we can create textures.
bool begin(SDL_Renderer* renderer, const std::string& path);
private:
enum class Phase {
FadeInFirstFrame,
Playing,
FadeOutToBlack,
Done
};
void startAudioIfReady();
void stopAudio();
static bool decodeAudioPcm16Stereo44100(
const std::string& path,
std::vector<int16_t>& outPcm,
int& outRate,
int& outChannels
);
std::unique_ptr<VideoPlayer> m_player;
std::string m_path;
Phase m_phase = Phase::FadeInFirstFrame;
double m_phaseClockMs = 0.0;
static constexpr double FADE_IN_MS = 900.0;
static constexpr double FADE_OUT_MS = 450.0;
// Audio decoding runs in the background while we fade in.
std::atomic<bool> m_audioDecoded{false};
std::atomic<bool> m_audioDecodeFailed{false};
std::vector<int16_t> m_audioPcm;
int m_audioRate = 44100;
int m_audioChannels = 2;
bool m_audioStarted = false;
std::unique_ptr<std::jthread> m_audioThread;
// Render-time overlay alpha (0..1) for fade stages.
float m_blackOverlayAlpha = 1.0f;
};

172
src/video/VideoPlayer.cpp Normal file
View File

@ -0,0 +1,172 @@
#include "VideoPlayer.h"
#include <iostream>
#include <chrono>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswscale/swscale.h>
#include <libavutil/imgutils.h>
}
VideoPlayer::VideoPlayer() {}
VideoPlayer::~VideoPlayer() {
if (m_texture) SDL_DestroyTexture(m_texture);
if (m_rgbBuffer) av_free(m_rgbBuffer);
if (m_frame) av_frame_free(&m_frame);
if (m_sws) sws_freeContext(m_sws);
if (m_dec) avcodec_free_context(&m_dec);
if (m_fmt) avformat_close_input(&m_fmt);
}
bool VideoPlayer::open(const std::string& path, SDL_Renderer* renderer) {
m_path = path;
avformat_network_init();
if (avformat_open_input(&m_fmt, path.c_str(), nullptr, nullptr) != 0) {
std::cerr << "VideoPlayer: failed to open " << path << "\n";
return false;
}
if (avformat_find_stream_info(m_fmt, nullptr) < 0) {
std::cerr << "VideoPlayer: failed to find stream info\n";
return false;
}
// Find video stream
m_videoStream = -1;
for (unsigned i = 0; i < m_fmt->nb_streams; ++i) {
if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { m_videoStream = (int)i; break; }
}
if (m_videoStream < 0) { std::cerr << "VideoPlayer: no video stream\n"; return false; }
AVCodecParameters* codecpar = m_fmt->streams[m_videoStream]->codecpar;
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
if (!codec) { std::cerr << "VideoPlayer: decoder not found\n"; return false; }
m_dec = avcodec_alloc_context3(codec);
if (!m_dec) { std::cerr << "VideoPlayer: failed to alloc codec ctx\n"; return false; }
if (avcodec_parameters_to_context(m_dec, codecpar) < 0) { std::cerr << "VideoPlayer: param to ctx failed\n"; return false; }
if (avcodec_open2(m_dec, codec, nullptr) < 0) { std::cerr << "VideoPlayer: open codec failed\n"; return false; }
m_width = m_dec->width;
m_height = m_dec->height;
m_frame = av_frame_alloc();
m_sws = sws_getContext(m_width, m_height, m_dec->pix_fmt, m_width, m_height, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr);
m_rgbBufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGBA, m_width, m_height, 1);
m_rgbBuffer = (uint8_t*)av_malloc(m_rgbBufferSize);
if (renderer) {
m_texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, m_width, m_height);
if (!m_texture) { std::cerr << "VideoPlayer: failed create texture\n"; }
}
m_finished = false;
m_textureReady = false;
m_started = false;
m_frameAccumulatorMs = 0.0;
// Estimate frame interval.
m_frameIntervalMs = 33.333;
if (m_fmt && m_videoStream >= 0) {
AVRational fr = m_fmt->streams[m_videoStream]->avg_frame_rate;
if (fr.num > 0 && fr.den > 0) {
const double fps = av_q2d(fr);
if (fps > 1.0) {
m_frameIntervalMs = 1000.0 / fps;
}
}
}
// Seek to start
av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD);
if (m_dec) avcodec_flush_buffers(m_dec);
return true;
}
bool VideoPlayer::decodeOneFrame() {
if (m_finished || !m_fmt) return false;
AVPacket* pkt = av_packet_alloc();
if (!pkt) {
m_finished = true;
return false;
}
int ret = 0;
while (av_read_frame(m_fmt, pkt) >= 0) {
if (pkt->stream_index == m_videoStream) {
ret = avcodec_send_packet(m_dec, pkt);
if (ret < 0) {
av_packet_unref(pkt);
continue;
}
while (ret >= 0) {
ret = avcodec_receive_frame(m_dec, m_frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
if (ret < 0) break;
uint8_t* dstData[4] = { m_rgbBuffer, nullptr, nullptr, nullptr };
int dstLinesize[4] = { m_width * 4, 0, 0, 0 };
sws_scale(m_sws, m_frame->data, m_frame->linesize, 0, m_height, dstData, dstLinesize);
m_textureReady = true;
if (m_texture) {
SDL_UpdateTexture(m_texture, nullptr, m_rgbBuffer, dstLinesize[0]);
}
av_frame_unref(m_frame);
av_packet_unref(pkt);
av_packet_free(&pkt);
return true;
}
}
av_packet_unref(pkt);
}
av_packet_free(&pkt);
m_finished = true;
return false;
}
bool VideoPlayer::decodeFirstFrame() {
if (!m_fmt || m_finished) return false;
if (m_textureReady) return true;
// Ensure we are at the beginning.
av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD);
if (m_dec) avcodec_flush_buffers(m_dec);
return decodeOneFrame();
}
void VideoPlayer::start() {
m_started = true;
}
bool VideoPlayer::update(double deltaMs) {
if (m_finished || !m_fmt) return false;
if (!m_started) return true;
m_frameAccumulatorMs += deltaMs;
// Decode at most a small burst per frame to avoid spiral-of-death.
int framesDecoded = 0;
const int maxFramesPerTick = 4;
while (m_frameAccumulatorMs >= m_frameIntervalMs && framesDecoded < maxFramesPerTick) {
m_frameAccumulatorMs -= m_frameIntervalMs;
if (!decodeOneFrame()) {
return false;
}
++framesDecoded;
}
return !m_finished;
}
bool VideoPlayer::update() {
// Legacy behavior: decode exactly one frame.
return decodeOneFrame();
}
void VideoPlayer::render(SDL_Renderer* renderer, int winW, int winH) {
if (!m_textureReady || !m_texture || !renderer) return;
if (winW <= 0 || winH <= 0) return;
SDL_FRect dst = { 0.0f, 0.0f, (float)winW, (float)winH };
SDL_RenderTexture(renderer, m_texture, nullptr, &dst);
}

59
src/video/VideoPlayer.h Normal file
View File

@ -0,0 +1,59 @@
// Minimal FFmpeg-based video player (video) that decodes into an SDL texture.
// Audio for the intro is currently handled outside this class.
#pragma once
#include <string>
#include <SDL3/SDL.h>
struct AVFormatContext;
struct AVCodecContext;
struct SwsContext;
struct AVFrame;
class VideoPlayer {
public:
VideoPlayer();
~VideoPlayer();
// Open video file and attach to SDL_Renderer for texture creation
bool open(const std::string& path, SDL_Renderer* renderer);
// Decode the first frame immediately so it can be used for fade-in.
bool decodeFirstFrame();
// Start time-based playback.
void start();
// Update playback using elapsed time in milliseconds.
// Returns false if finished or error.
bool update(double deltaMs);
// Compatibility: advance by one decoded frame.
bool update();
// Render video frame fullscreen to the given renderer using provided output size.
void render(SDL_Renderer* renderer, int winW, int winH);
bool isFinished() const { return m_finished; }
bool isTextureReady() const { return m_textureReady; }
double getFrameIntervalMs() const { return m_frameIntervalMs; }
bool isStarted() const { return m_started; }
private:
bool decodeOneFrame();
AVFormatContext* m_fmt = nullptr;
AVCodecContext* m_dec = nullptr;
SwsContext* m_sws = nullptr;
AVFrame* m_frame = nullptr;
int m_videoStream = -1;
double m_frameIntervalMs = 33.333;
double m_frameAccumulatorMs = 0.0;
bool m_started = false;
int m_width = 0, m_height = 0;
SDL_Texture* m_texture = nullptr;
uint8_t* m_rgbBuffer = nullptr;
int m_rgbBufferSize = 0;
bool m_textureReady = false;
bool m_finished = true;
std::string m_path;
};

38
tests/test_board.cpp Normal file
View File

@ -0,0 +1,38 @@
#include "../src/logic/Board.h"
#include <gtest/gtest.h>
using logic::Board;
TEST(BoardTests, InitiallyEmpty)
{
Board b;
for (int y = 0; y < Board::Height; ++y)
for (int x = 0; x < Board::Width; ++x)
EXPECT_EQ(b.at(x, y), Board::Cell::Empty);
}
TEST(BoardTests, ClearSingleFullLine)
{
Board b;
int y = Board::Height - 1;
for (int x = 0; x < Board::Width; ++x) b.set(x, y, Board::Cell::Filled);
int cleared = b.clearFullLines();
EXPECT_EQ(cleared, 1);
for (int x = 0; x < Board::Width; ++x) EXPECT_EQ(b.at(x, Board::Height - 1), Board::Cell::Empty);
}
TEST(BoardTests, ClearTwoNonAdjacentLines)
{
Board b;
int y1 = Board::Height - 1;
int y2 = Board::Height - 3;
for (int x = 0; x < Board::Width; ++x) { b.set(x, y1, Board::Cell::Filled); b.set(x, y2, Board::Cell::Filled); }
int cleared = b.clearFullLines();
EXPECT_EQ(cleared, 2);
}
int main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View File

@ -6,8 +6,11 @@
"name": "sdl3-image", "name": "sdl3-image",
"features": ["jpeg", "png", "webp"] "features": ["jpeg", "png", "webp"]
}, },
"enet",
"catch2", "catch2",
"gtest",
"cpr", "cpr",
"nlohmann-json" "nlohmann-json",
"ffmpeg"
] ]
} }