Compare commits
23 Commits
c14e305a4a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 516aa16737 | |||
| 735e966608 | |||
| 68b35ea57b | |||
| 938988c876 | |||
| 03bdc82dc1 | |||
| 17cb64c9d4 | |||
| 6ef93e4c9c | |||
| e2dd768faf | |||
| 0b546ce25c | |||
| 45086e58d8 | |||
| b1f2033880 | |||
| 5fd3febd8e | |||
| 60d6a9e740 | |||
| e1921858ed | |||
| 14cb96345c | |||
| d28feb3276 | |||
| a7a3ae9055 | |||
| 5ec4bf926b | |||
| 0e04617968 | |||
| b450e2af21 | |||
| a65756f298 | |||
| dac312ef2b | |||
| 953d6af701 |
168
.copilot-rules.md
Normal file
168
.copilot-rules.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# Copilot Rules — Spacetris (SDL3 / C++20)
|
||||||
|
|
||||||
|
These rules define **non-negotiable constraints** for all AI-assisted changes.
|
||||||
|
They exist to preserve determinism, performance, and architecture.
|
||||||
|
|
||||||
|
If these rules conflict with `.github/copilot-instructions.md`,
|
||||||
|
**follow `.github/copilot-instructions.md`.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Constraints (Non-Negotiable)
|
||||||
|
|
||||||
|
- Language: **C++20**
|
||||||
|
- Runtime: **SDL3** + **SDL3_ttf**
|
||||||
|
- Build system: **CMake**
|
||||||
|
- Dependencies via **vcpkg**
|
||||||
|
- Assets must use **relative paths only**
|
||||||
|
- Deterministic gameplay logic is mandatory
|
||||||
|
|
||||||
|
Do not rewrite or refactor working systems unless explicitly requested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo Layout & Responsibilities
|
||||||
|
|
||||||
|
- Core gameplay loop/state: `src/Game.*`
|
||||||
|
- Entry point: `src/main.cpp`
|
||||||
|
- Text/TTF: `src/Font.*`
|
||||||
|
- Audio: `src/Audio.*`, `src/SoundEffect.*`
|
||||||
|
- Effects: `src/LineEffect.*`, `src/Starfield*.cpp`
|
||||||
|
- High scores: `src/Scores.*`
|
||||||
|
- Packaging: `build-production.ps1`
|
||||||
|
|
||||||
|
When adding a module:
|
||||||
|
- Place it under `src/` (or an established subfolder)
|
||||||
|
- Register it in `CMakeLists.txt`
|
||||||
|
- Avoid circular includes
|
||||||
|
- Keep headers minimal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Verification
|
||||||
|
|
||||||
|
Prefer existing scripts:
|
||||||
|
|
||||||
|
- Debug: `cmake --build build-msvc --config Debug`
|
||||||
|
- Release:
|
||||||
|
- Configure: `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release`
|
||||||
|
- Build: `cmake --build build-release --config Release`
|
||||||
|
- Packaging (Windows): `./build-production.ps1`
|
||||||
|
|
||||||
|
Before finalizing changes:
|
||||||
|
- Debug build must succeed
|
||||||
|
- Packaging must succeed if assets or DLLs are touched
|
||||||
|
|
||||||
|
Do not introduce new build steps unless required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding & Architecture Rules
|
||||||
|
|
||||||
|
- Match local file style (naming, braces, spacing)
|
||||||
|
- Avoid large refactors
|
||||||
|
- Prefer small, testable helpers
|
||||||
|
- Avoid floating-point math in core gameplay state
|
||||||
|
- Game logic must be deterministic
|
||||||
|
- Rendering code must not mutate game state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rendering & Performance Rules
|
||||||
|
|
||||||
|
- Do not allocate memory per frame
|
||||||
|
- Do not load assets during rendering
|
||||||
|
- No blocking calls in render loop
|
||||||
|
- Visual effects must be time-based (`deltaTime`)
|
||||||
|
- Rendering must not contain gameplay logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threading Rules
|
||||||
|
|
||||||
|
- SDL main thread:
|
||||||
|
- Rendering
|
||||||
|
- Input
|
||||||
|
- Game simulation
|
||||||
|
- Networking must be **non-blocking** from the SDL main loop
|
||||||
|
- Either run networking on a separate thread, or poll ENet frequently with a 0 timeout
|
||||||
|
- Never wait/spin for remote inputs on the render thread
|
||||||
|
- Cross-thread communication via queues or buffers only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets, Fonts, and Paths
|
||||||
|
|
||||||
|
- Runtime expects adjacent `assets/` directory
|
||||||
|
- `FreeSans.ttf` must remain at repo root
|
||||||
|
- New assets:
|
||||||
|
- Go under `assets/`
|
||||||
|
- Must be included in `build-production.ps1`
|
||||||
|
|
||||||
|
Never hardcode machine-specific paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Partner (COOPERATE Mode)
|
||||||
|
|
||||||
|
- AI is **supportive**, not competitive
|
||||||
|
- AI must respect sync timing and shared grid logic
|
||||||
|
- AI must not “cheat” or see hidden future pieces
|
||||||
|
- AI behavior must be deterministic per seed/difficulty
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Networking (COOPERATE Network Mode)
|
||||||
|
|
||||||
|
Follow `docs/ai/cooperate_network.md`.
|
||||||
|
If `network_cooperate_multiplayer.md` exists, keep it consistent with the canonical doc.
|
||||||
|
|
||||||
|
Mandatory model:
|
||||||
|
- **Input lockstep**
|
||||||
|
- Transmit inputs only (no board state replication)
|
||||||
|
|
||||||
|
Determinism requirements:
|
||||||
|
- Fixed tick (e.g. 60 Hz)
|
||||||
|
- Shared RNG seed
|
||||||
|
- Deterministic gravity, rotation, locking, scoring
|
||||||
|
|
||||||
|
Technology:
|
||||||
|
- Use **ENet**
|
||||||
|
- Do NOT use SDL_net or TCP-only networking
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- Networking must be isolated (e.g. `src/network/NetSession.*`)
|
||||||
|
- Game logic must not care if partner is local, AI, or network
|
||||||
|
|
||||||
|
Robustness:
|
||||||
|
- Input delay buffer (4–6 ticks)
|
||||||
|
- Periodic desync hashing
|
||||||
|
- Graceful disconnect handling
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
- Rollback
|
||||||
|
- Full state sync
|
||||||
|
- Server-authoritative sim
|
||||||
|
- Matchmaking SDKs
|
||||||
|
- Versus mechanics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Behavior Rules (IMPORTANT)
|
||||||
|
|
||||||
|
- Always read relevant markdown specs **before coding**
|
||||||
|
- Treat markdown specs as authoritative
|
||||||
|
- Do not invent APIs
|
||||||
|
- Do not assume external libraries exist
|
||||||
|
- Generate code **file by file**, not everything at once
|
||||||
|
- Ask before changing architecture or ownership boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Ask Before Proceeding
|
||||||
|
|
||||||
|
Ask the maintainer if unclear:
|
||||||
|
- UX or menu flow decisions
|
||||||
|
- Adding dependencies
|
||||||
|
- Refactors vs local patches
|
||||||
|
- Platform-specific behavior
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -28,12 +28,14 @@ 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
|
||||||
src/app/TetrisApp.cpp
|
src/app/TetrisApp.cpp
|
||||||
src/gameplay/core/Game.cpp
|
src/gameplay/core/Game.cpp
|
||||||
src/gameplay/coop/CoopGame.cpp
|
src/gameplay/coop/CoopGame.cpp
|
||||||
|
src/gameplay/coop/CoopAIController.cpp
|
||||||
src/core/GravityManager.cpp
|
src/core/GravityManager.cpp
|
||||||
src/core/state/StateManager.cpp
|
src/core/state/StateManager.cpp
|
||||||
# New core architecture classes
|
# New core architecture classes
|
||||||
@ -45,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
|
||||||
@ -54,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
|
||||||
@ -159,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
|
||||||
@ -189,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
|
||||||
|
|||||||
BIN
assets/images/cooperate_info.png
Normal file
BIN
assets/images/cooperate_info.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
BIN
assets/videos/spacetris_intro.mp4
Normal file
BIN
assets/videos/spacetris_intro.mp4
Normal file
Binary file not shown.
271
docs/ai/cooperate_network.md
Normal file
271
docs/ai/cooperate_network.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# Spacetris — COOPERATE Mode
|
||||||
|
## Network Multiplayer (2 PLAYER – NETWORK)
|
||||||
|
### VS Code Copilot AI Agent Prompt
|
||||||
|
|
||||||
|
You are integrating **online cooperative multiplayer** into an existing **C++ / SDL3 game** called **Spacetris**.
|
||||||
|
|
||||||
|
This feature extends the existing **COOPERATE mode** to support:
|
||||||
|
- Local 2 players
|
||||||
|
- Human + AI
|
||||||
|
- **Human + Human over network (NEW)**
|
||||||
|
|
||||||
|
The networking solution must be **deterministic, lightweight, and stable**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. High-Level Goal
|
||||||
|
|
||||||
|
Add **COOPERATE 2 PLAYER (NETWORK)** mode where:
|
||||||
|
- Two players play together over the internet
|
||||||
|
- Each player controls one half of the shared grid
|
||||||
|
- A line clears only when both halves are filled
|
||||||
|
- Gameplay remains identical to local COOPERATE mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technology Constraints
|
||||||
|
|
||||||
|
- Language: **C++**
|
||||||
|
- Engine: **SDL3**
|
||||||
|
- Networking: **ENet (UDP with reliability)**
|
||||||
|
- No engine rewrite
|
||||||
|
- No authoritative server logic required (co-op only)
|
||||||
|
|
||||||
|
SDL3 is used ONLY for:
|
||||||
|
- Rendering
|
||||||
|
- Input
|
||||||
|
- Timing
|
||||||
|
|
||||||
|
Networking is a **separate layer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Network Model (MANDATORY)
|
||||||
|
|
||||||
|
### Use **Input Lockstep Networking**
|
||||||
|
|
||||||
|
#### Core idea:
|
||||||
|
- Both clients run the same deterministic simulation
|
||||||
|
- Only **player inputs** are sent over the network
|
||||||
|
- No board state is transmitted
|
||||||
|
- Both simulations must remain identical
|
||||||
|
|
||||||
|
This model is ideal for Tetris-like games.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Determinism Requirements (CRITICAL)
|
||||||
|
|
||||||
|
To ensure lockstep works:
|
||||||
|
|
||||||
|
- Fixed simulation tick (e.g. 60 Hz)
|
||||||
|
- Identical RNG seed for both clients
|
||||||
|
- Deterministic piece generation (bag system)
|
||||||
|
- No floating-point math in core gameplay
|
||||||
|
- Same gravity, rotation, lock-delay logic
|
||||||
|
- Identical line clear and scoring rules
|
||||||
|
|
||||||
|
Before networking:
|
||||||
|
- Input recording + replay must produce identical results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Network Topology
|
||||||
|
|
||||||
|
### Host / Client Model (Initial Implementation)
|
||||||
|
|
||||||
|
- One player hosts the game
|
||||||
|
- One player joins
|
||||||
|
- Host is authoritative for:
|
||||||
|
- RNG seed
|
||||||
|
- start tick
|
||||||
|
- game settings
|
||||||
|
|
||||||
|
This is sufficient and fair for cooperative gameplay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Network Library
|
||||||
|
|
||||||
|
Use **ENet** for:
|
||||||
|
- Reliable, ordered UDP packets
|
||||||
|
- Low latency
|
||||||
|
- Simple integration with C++
|
||||||
|
|
||||||
|
Do NOT use:
|
||||||
|
- SDL_net
|
||||||
|
- TCP-only networking
|
||||||
|
- High-level matchmaking SDKs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Network Packet Design
|
||||||
|
|
||||||
|
### Input Packet (Minimal)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct InputPacket {
|
||||||
|
uint32_t tick;
|
||||||
|
uint8_t buttons; // bitmask
|
||||||
|
};
|
||||||
|
````
|
||||||
|
|
||||||
|
Button bitmask example:
|
||||||
|
|
||||||
|
* bit 0 → move left
|
||||||
|
* bit 1 → move right
|
||||||
|
* bit 2 → rotate
|
||||||
|
* bit 3 → soft drop
|
||||||
|
* bit 4 → hard drop
|
||||||
|
* bit 5 → hold
|
||||||
|
|
||||||
|
Packets must be:
|
||||||
|
|
||||||
|
* Reliable
|
||||||
|
* Ordered
|
||||||
|
* Small
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tick & Latency Handling
|
||||||
|
|
||||||
|
### Input Delay Buffer (RECOMMENDED)
|
||||||
|
|
||||||
|
* Add fixed delay: **4–6 ticks**
|
||||||
|
* Simulate tick `T` using inputs for `T + delay`
|
||||||
|
* Prevents stalls due to latency spikes
|
||||||
|
|
||||||
|
Strict lockstep without buffering is NOT recommended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Desync Detection (IMPORTANT)
|
||||||
|
|
||||||
|
Every N ticks (e.g. once per second):
|
||||||
|
|
||||||
|
* Compute a hash of:
|
||||||
|
|
||||||
|
* Both grid halves
|
||||||
|
* Active pieces
|
||||||
|
* RNG index
|
||||||
|
* Score / lines / level
|
||||||
|
* Exchange hashes
|
||||||
|
* If mismatch:
|
||||||
|
|
||||||
|
* Log desync
|
||||||
|
* Stop game or mark session invalid
|
||||||
|
|
||||||
|
This is required for debugging and stability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Network Session Architecture
|
||||||
|
|
||||||
|
Create a dedicated networking module:
|
||||||
|
|
||||||
|
```
|
||||||
|
/network
|
||||||
|
NetSession.h
|
||||||
|
NetSession.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* ENet host/client setup
|
||||||
|
* Input packet send/receive
|
||||||
|
* Tick synchronization
|
||||||
|
* Latency buffering
|
||||||
|
* Disconnect handling
|
||||||
|
|
||||||
|
SDL main loop must NOT block on networking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Integration with Existing COOPERATE Logic
|
||||||
|
|
||||||
|
* COOPERATE grid logic stays unchanged
|
||||||
|
* SyncLineRenderer remains unchanged
|
||||||
|
* Scoring logic remains unchanged
|
||||||
|
* Network layer only injects **remote inputs**
|
||||||
|
|
||||||
|
Game logic should not know whether partner is:
|
||||||
|
|
||||||
|
* Local human
|
||||||
|
* AI
|
||||||
|
* Network player
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. UI Integration (Menu Changes)
|
||||||
|
|
||||||
|
In COOPERATE selection screen, add a new button:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ LOCAL CO-OP ] [ AI PARTNER ] [ 2 PLAYER (NETWORK) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### On selecting 2 PLAYER (NETWORK):
|
||||||
|
|
||||||
|
* Show:
|
||||||
|
|
||||||
|
* Host Game
|
||||||
|
* Join Game
|
||||||
|
* Display join code or IP
|
||||||
|
* Confirm connection before starting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Start Game Flow (Network)
|
||||||
|
|
||||||
|
1. Host creates session
|
||||||
|
2. Client connects
|
||||||
|
3. Host sends:
|
||||||
|
|
||||||
|
* RNG seed
|
||||||
|
* start tick
|
||||||
|
* game settings
|
||||||
|
4. Both wait until agreed start tick
|
||||||
|
5. Simulation begins simultaneously
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Disconnect & Error Handling
|
||||||
|
|
||||||
|
* If connection drops:
|
||||||
|
|
||||||
|
* Pause game
|
||||||
|
* Show “Reconnecting…”
|
||||||
|
* After timeout:
|
||||||
|
|
||||||
|
* End match or switch to AI (optional)
|
||||||
|
* Never crash
|
||||||
|
* Never corrupt game state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. What NOT to Implement
|
||||||
|
|
||||||
|
* ❌ Full state synchronization
|
||||||
|
* ❌ Prediction / rollback
|
||||||
|
* ❌ Server-authoritative gameplay
|
||||||
|
* ❌ Complex matchmaking
|
||||||
|
* ❌ Versus mechanics
|
||||||
|
|
||||||
|
This is cooperative, not competitive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Acceptance Criteria
|
||||||
|
|
||||||
|
* Two players can complete COOPERATE mode over network
|
||||||
|
* Gameplay matches local COOPERATE exactly
|
||||||
|
* No noticeable input lag under normal latency
|
||||||
|
* Desync detection works
|
||||||
|
* Offline / disconnect handled gracefully
|
||||||
|
* SDL3 render loop remains smooth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Summary for Copilot
|
||||||
|
|
||||||
|
Integrate networked cooperative multiplayer into Spacetris using SDL3 + C++ with ENet. Implement input lockstep networking with deterministic simulation, fixed tick rate, input buffering, and desync detection. Add a new COOPERATE menu option “2 PLAYER (NETWORK)” that allows host/join flow. Networking must be modular, non-blocking, and transparent to existing gameplay logic.
|
||||||
0
scripts/check_braces.ps1
Normal file
0
scripts/check_braces.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
0
scripts/find_unmatched.ps1
Normal file
0
scripts/find_unmatched.ps1
Normal file
@ -5,7 +5,7 @@
|
|||||||
Fullscreen=1
|
Fullscreen=1
|
||||||
|
|
||||||
[Audio]
|
[Audio]
|
||||||
Music=0
|
Music=1
|
||||||
Sound=1
|
Sound=1
|
||||||
|
|
||||||
[Gameplay]
|
[Gameplay]
|
||||||
|
|||||||
@ -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,16 +73,18 @@ 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; }
|
||||||
} else {
|
|
||||||
SDL_Texture* tex = SDL_CreateTextureFromSurface(m_renderer, surf);
|
// Use TextureLoader to centralize loading and ResourceManager caching
|
||||||
SDL_DestroySurface(surf);
|
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) {
|
if (!tex) {
|
||||||
std::lock_guard<std::mutex> lk(m_errorsMutex);
|
// errors have been recorded by TextureLoader
|
||||||
m_errors.push_back(std::string("CreateTexture failed: ") + fullPath);
|
|
||||||
} else {
|
} else {
|
||||||
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];
|
||||||
@ -83,7 +93,6 @@ bool AssetLoader::performStep() {
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"
|
||||||
@ -38,6 +39,7 @@
|
|||||||
|
|
||||||
#include "gameplay/core/Game.h"
|
#include "gameplay/core/Game.h"
|
||||||
#include "gameplay/coop/CoopGame.h"
|
#include "gameplay/coop/CoopGame.h"
|
||||||
|
#include "gameplay/coop/CoopAIController.h"
|
||||||
#include "gameplay/effects/LineEffect.h"
|
#include "gameplay/effects/LineEffect.h"
|
||||||
|
|
||||||
#include "graphics/effects/SpaceWarp.h"
|
#include "graphics/effects/SpaceWarp.h"
|
||||||
@ -48,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"
|
||||||
@ -56,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"
|
||||||
@ -63,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;
|
||||||
@ -182,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;
|
||||||
@ -239,6 +247,11 @@ struct TetrisApp::Impl {
|
|||||||
bool suppressLineVoiceForLevelUp = false;
|
bool suppressLineVoiceForLevelUp = false;
|
||||||
bool skipNextLevelUpJingle = false;
|
bool skipNextLevelUpJingle = false;
|
||||||
|
|
||||||
|
// COOPERATE option: when true, right player is AI-controlled.
|
||||||
|
bool coopVsAI = false;
|
||||||
|
|
||||||
|
CoopAIController coopAI;
|
||||||
|
|
||||||
AppState state = AppState::Loading;
|
AppState state = AppState::Loading;
|
||||||
double loadingProgress = 0.0;
|
double loadingProgress = 0.0;
|
||||||
Uint64 loadStart = 0;
|
Uint64 loadStart = 0;
|
||||||
@ -253,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};
|
||||||
@ -295,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();
|
||||||
@ -401,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)
|
||||||
@ -410,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]() {
|
||||||
@ -567,6 +600,7 @@ int TetrisApp::Impl::init()
|
|||||||
ctx.mainScreenW = mainScreenW;
|
ctx.mainScreenW = mainScreenW;
|
||||||
ctx.mainScreenH = mainScreenH;
|
ctx.mainScreenH = mainScreenH;
|
||||||
ctx.musicEnabled = &musicEnabled;
|
ctx.musicEnabled = &musicEnabled;
|
||||||
|
ctx.coopVsAI = &coopVsAI;
|
||||||
ctx.startLevelSelection = &startLevelSelection;
|
ctx.startLevelSelection = &startLevelSelection;
|
||||||
ctx.hoveredButton = &hoveredButton;
|
ctx.hoveredButton = &hoveredButton;
|
||||||
ctx.showSettingsPopup = &showSettingsPopup;
|
ctx.showSettingsPopup = &showSettingsPopup;
|
||||||
@ -628,10 +662,17 @@ int TetrisApp::Impl::init()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state != AppState::Menu) {
|
if (state != AppState::Menu) {
|
||||||
|
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
|
||||||
|
coopAI.reset();
|
||||||
|
}
|
||||||
state = AppState::Playing;
|
state = AppState::Playing;
|
||||||
ctx.stateManager->setState(state);
|
ctx.stateManager->setState(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
|
||||||
|
coopAI.reset();
|
||||||
|
}
|
||||||
beginStateFade(AppState::Playing, true);
|
beginStateFade(AppState::Playing, true);
|
||||||
};
|
};
|
||||||
ctx.startPlayTransition = startMenuPlayTransition;
|
ctx.startPlayTransition = startMenuPlayTransition;
|
||||||
@ -648,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);
|
||||||
@ -658,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(); });
|
||||||
@ -789,19 +848,21 @@ 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();
|
||||||
|
if (!musicStarted && sys->getLoadedTrackCount() > 0) {
|
||||||
musicStarted = true;
|
musicStarted = true;
|
||||||
musicEnabled = true;
|
musicEnabled = true;
|
||||||
Settings::instance().setMusicEnabled(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)
|
||||||
if (e.key.scancode == SDL_SCANCODE_K)
|
if (e.key.scancode == SDL_SCANCODE_K)
|
||||||
{
|
{
|
||||||
@ -809,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;
|
||||||
@ -894,6 +955,23 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||||
if (isNewHighScore) {
|
if (isNewHighScore) {
|
||||||
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||||
|
if (coopVsAI) {
|
||||||
|
// One-name entry flow (CPU is LEFT, human enters RIGHT name)
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
|
||||||
|
if (!player2Name.empty()) player2Name.pop_back();
|
||||||
|
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
||||||
|
if (player2Name.empty()) player2Name = "P2";
|
||||||
|
std::string combined = std::string("CPU") + " & " + player2Name;
|
||||||
|
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||||
|
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||||
|
int combinedScore = leftScore + rightScore;
|
||||||
|
ensureScoresLoaded();
|
||||||
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
|
||||||
|
Settings::instance().setPlayerName(player2Name);
|
||||||
|
isNewHighScore = false;
|
||||||
|
SDL_StopTextInput(window);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Two-name entry flow
|
// Two-name entry flow
|
||||||
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
|
||||||
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
|
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
|
||||||
@ -916,6 +994,7 @@ void TetrisApp::Impl::runLoop()
|
|||||||
SDL_StopTextInput(window);
|
SDL_StopTextInput(window);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
|
||||||
playerName.pop_back();
|
playerName.pop_back();
|
||||||
@ -972,11 +1051,9 @@ void TetrisApp::Impl::runLoop()
|
|||||||
startMenuPlayTransition();
|
startMenuPlayTransition();
|
||||||
break;
|
break;
|
||||||
case ui::BottomMenuItem::Cooperate:
|
case ui::BottomMenuItem::Cooperate:
|
||||||
if (game) {
|
if (menuState) {
|
||||||
game->setMode(GameMode::Cooperate);
|
menuState->showCoopSetupPanel(true);
|
||||||
game->reset(startLevelSelection);
|
|
||||||
}
|
}
|
||||||
startMenuPlayTransition();
|
|
||||||
break;
|
break;
|
||||||
case ui::BottomMenuItem::Challenge:
|
case ui::BottomMenuItem::Challenge:
|
||||||
if (game) {
|
if (game) {
|
||||||
@ -1119,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;
|
||||||
@ -1229,11 +1325,15 @@ 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)) {
|
||||||
|
sys->shuffle();
|
||||||
musicLoaded = true;
|
musicLoaded = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
currentTrackLoading = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state == AppState::Playing)
|
if (state == AppState::Playing)
|
||||||
@ -1279,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;
|
||||||
@ -1288,17 +1392,248 @@ void TetrisApp::Impl::runLoop()
|
|||||||
p2LeftHeld = false;
|
p2LeftHeld = false;
|
||||||
p2RightHeld = false;
|
p2RightHeld = false;
|
||||||
} else {
|
} else {
|
||||||
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
|
||||||
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
|
||||||
|
|
||||||
p1LeftHeld = ks[SDL_SCANCODE_A];
|
// If we just entered network co-op, reset per-session fixed-tick bookkeeping.
|
||||||
p1RightHeld = ks[SDL_SCANCODE_D];
|
if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) {
|
||||||
p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
coopNetAccMs = 0.0;
|
||||||
p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
coopNetCachedTick = 0xFFFFFFFFu;
|
||||||
|
coopNetCachedButtons = 0;
|
||||||
|
coopNetLastHashSentTick = 0xFFFFFFFFu;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define canonical key mappings for left and right players
|
||||||
|
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
|
||||||
|
const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
|
||||||
|
const SDL_Scancode leftDownKey = SDL_SCANCODE_S;
|
||||||
|
|
||||||
|
const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT;
|
||||||
|
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
|
||||||
|
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
|
||||||
|
|
||||||
|
if (coopNetActive) {
|
||||||
|
// Network co-op: fixed tick lockstep.
|
||||||
|
// Use a fixed dt so both peers simulate identically.
|
||||||
|
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
|
||||||
|
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
|
||||||
|
|
||||||
|
ctx.coopNetSession->poll(0);
|
||||||
|
|
||||||
|
// If the connection drops during gameplay, abort back to menu.
|
||||||
|
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
|
||||||
|
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
|
||||||
|
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
|
||||||
|
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
|
||||||
|
: std::string("NET DISCONNECTED");
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
|
||||||
|
|
||||||
|
ctx.coopNetUiStatusText = reason;
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 6000.0;
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
ctx.coopNetDesyncDetected = false;
|
||||||
|
ctx.coopNetTick = 0;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't remain paused due to a previous net stall/desync.
|
||||||
|
if (game) {
|
||||||
|
game->setPaused(false);
|
||||||
|
}
|
||||||
|
state = AppState::Menu;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
|
||||||
|
|
||||||
|
auto buildLocalButtons = [&]() -> uint8_t {
|
||||||
|
uint8_t b = 0;
|
||||||
|
if (ctx.coopNetLocalIsLeft) {
|
||||||
|
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
|
||||||
|
if (ks[leftRightKey]) b |= coopnet::MoveRight;
|
||||||
|
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
|
||||||
|
} else {
|
||||||
|
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
|
||||||
|
if (ks[rightRightKey]) b |= coopnet::MoveRight;
|
||||||
|
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
|
||||||
|
}
|
||||||
|
b |= ctx.coopNetPendingButtons;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
|
||||||
|
uint8_t buttons,
|
||||||
|
bool& leftHeldPrev,
|
||||||
|
bool& rightHeldPrev,
|
||||||
|
double& timer) {
|
||||||
|
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
|
||||||
|
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
|
||||||
|
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
|
||||||
|
|
||||||
|
coopGame->setSoftDropping(side, downHeldNow);
|
||||||
|
|
||||||
|
int moveDir = 0;
|
||||||
|
if (leftHeldNow && !rightHeldNow) moveDir = -1;
|
||||||
|
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= FIXED_DT_MS;
|
||||||
|
if (timer <= 0.0) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coopnet::has(buttons, coopnet::RotCW)) {
|
||||||
|
coopGame->rotate(side, +1);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::RotCCW)) {
|
||||||
|
coopGame->rotate(side, -1);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::HardDrop)) {
|
||||||
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
|
coopGame->hardDrop(side);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::Hold)) {
|
||||||
|
coopGame->holdCurrent(side);
|
||||||
|
}
|
||||||
|
|
||||||
|
leftHeldPrev = leftHeldNow;
|
||||||
|
rightHeldPrev = rightHeldNow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
|
||||||
|
|
||||||
|
int safetySteps = 0;
|
||||||
|
bool advancedTick = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
|
||||||
|
const uint32_t tick = ctx.coopNetTick;
|
||||||
|
|
||||||
|
if (coopNetCachedTick != tick) {
|
||||||
|
coopNetCachedTick = tick;
|
||||||
|
coopNetCachedButtons = buildLocalButtons();
|
||||||
|
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
|
||||||
|
roleStr,
|
||||||
|
tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
|
||||||
|
if (!remoteButtonsOpt.has_value()) {
|
||||||
|
if (!ctx.coopNetStalled) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] STALL begin waitingForTick=%u",
|
||||||
|
roleStr,
|
||||||
|
tick);
|
||||||
|
}
|
||||||
|
ctx.coopNetStalled = true;
|
||||||
|
break; // lockstep stall
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t remoteButtons = remoteButtonsOpt.value();
|
||||||
|
const bool localIsLeft = ctx.coopNetLocalIsLeft;
|
||||||
|
|
||||||
|
if (localIsLeft) {
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
|
||||||
|
} else {
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
coopGame->tickGravity(FIXED_DT_MS);
|
||||||
|
coopGame->updateVisualEffects(FIXED_DT_MS);
|
||||||
|
|
||||||
|
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
|
||||||
|
coopNetLastHashSentTick = tick;
|
||||||
|
const uint64_t hash = coopGame->computeStateHash();
|
||||||
|
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
|
||||||
|
roleStr,
|
||||||
|
tick,
|
||||||
|
(unsigned long long)hash);
|
||||||
|
}
|
||||||
|
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
|
||||||
|
if (rh.has_value() && rh.value() != hash) {
|
||||||
|
ctx.coopNetDesyncDetected = true;
|
||||||
|
ctx.coopNetUiStatusText = "NET DESYNC";
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 8000.0;
|
||||||
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
|
||||||
|
roleStr,
|
||||||
|
tick,
|
||||||
|
(unsigned long long)hash,
|
||||||
|
(unsigned long long)rh.value());
|
||||||
|
game->setPaused(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.coopNetTick++;
|
||||||
|
advancedTick = true;
|
||||||
|
coopNetAccMs -= FIXED_DT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advancedTick) {
|
||||||
|
if (ctx.coopNetStalled) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] STALL end atTick=%u",
|
||||||
|
roleStr,
|
||||||
|
ctx.coopNetTick);
|
||||||
|
}
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
} else if (!coopVsAI) {
|
||||||
|
// Standard two-player: left uses WASD, right uses arrow keys
|
||||||
|
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
|
||||||
|
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
|
||||||
|
|
||||||
|
p1LeftHeld = ks[leftLeftKey];
|
||||||
|
p1RightHeld = ks[leftRightKey];
|
||||||
|
p2LeftHeld = ks[rightLeftKey];
|
||||||
|
p2RightHeld = ks[rightRightKey];
|
||||||
|
} else {
|
||||||
|
// Coop vs CPU: AI controls LEFT, human controls RIGHT (arrow keys).
|
||||||
|
// Handle continuous input for the human on the right side.
|
||||||
|
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
|
||||||
|
|
||||||
|
// Mirror the human soft-drop to the AI-controlled left board so both fall together.
|
||||||
|
const bool pRightSoftDrop = ks[rightDownKey];
|
||||||
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, pRightSoftDrop);
|
||||||
|
|
||||||
|
// Reset left continuous timers/held flags (AI handles movement)
|
||||||
|
p1MoveTimerMs = 0.0;
|
||||||
|
p1LeftHeld = false;
|
||||||
|
p1RightHeld = false;
|
||||||
|
|
||||||
|
// Update AI for the left side
|
||||||
|
coopAI.update(*coopGame, CoopGame::PlayerSide::Left, frameMs);
|
||||||
|
// Update human-held flags for right-side controls so DAS/ARR state is tracked
|
||||||
|
p2LeftHeld = ks[rightLeftKey];
|
||||||
|
p2RightHeld = ks[rightRightKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coopNetActive) {
|
||||||
coopGame->tickGravity(frameMs);
|
coopGame->tickGravity(frameMs);
|
||||||
coopGame->updateVisualEffects(frameMs);
|
coopGame->updateVisualEffects(frameMs);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (coopGame->isGameOver()) {
|
if (coopGame->isGameOver()) {
|
||||||
// Compute combined coop stats for Game Over
|
// Compute combined coop stats for Game Over
|
||||||
@ -1307,17 +1642,31 @@ void TetrisApp::Impl::runLoop()
|
|||||||
int combinedScore = leftScore + rightScore;
|
int combinedScore = leftScore + rightScore;
|
||||||
if (combinedScore > 0) {
|
if (combinedScore > 0) {
|
||||||
isNewHighScore = true;
|
isNewHighScore = true;
|
||||||
|
if (coopVsAI) {
|
||||||
|
// AI is left, prompt human (right) for name
|
||||||
|
playerName = "CPU";
|
||||||
|
player2Name.clear();
|
||||||
|
highScoreEntryIndex = 1; // enter P2 (human)
|
||||||
|
} else {
|
||||||
playerName.clear();
|
playerName.clear();
|
||||||
player2Name.clear();
|
player2Name.clear();
|
||||||
highScoreEntryIndex = 0;
|
highScoreEntryIndex = 0;
|
||||||
|
}
|
||||||
SDL_StartTextInput(window);
|
SDL_StartTextInput(window);
|
||||||
} else {
|
} else {
|
||||||
isNewHighScore = false;
|
isNewHighScore = false;
|
||||||
ensureScoresLoaded();
|
ensureScoresLoaded();
|
||||||
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate");
|
// When AI is present, label should indicate CPU left and human right
|
||||||
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), coopVsAI ? "CPU & P2" : "P1 & P2", "cooperate");
|
||||||
}
|
}
|
||||||
state = AppState::GameOver;
|
state = AppState::GameOver;
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
|
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@ -1370,23 +1719,29 @@ void TetrisApp::Impl::runLoop()
|
|||||||
currentLoadingFile.clear();
|
currentLoadingFile.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
Audio::instance().init();
|
if (auto sys = AudioManager::get()) {
|
||||||
|
sys->init();
|
||||||
totalTracks = 0;
|
totalTracks = 0;
|
||||||
for (int i = 1; i <= 100; ++i) {
|
for (int i = 1; i <= 100; ++i) {
|
||||||
char base[128];
|
char base[128];
|
||||||
std::snprintf(base, sizeof(base), "assets/music/music%03d", i);
|
std::snprintf(base, sizeof(base), "assets/music/music%03d", i);
|
||||||
std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" });
|
std::string path = AssetPath::resolveWithExtensions(base, { ".mp3" });
|
||||||
if (path.empty()) break;
|
if (path.empty()) break;
|
||||||
Audio::instance().addTrackAsync(path);
|
sys->addTrackAsync(path);
|
||||||
totalTracks++;
|
totalTracks++;
|
||||||
}
|
}
|
||||||
totalLoadingTasks.store(baseTasks + totalTracks);
|
totalLoadingTasks.store(baseTasks + totalTracks);
|
||||||
if (totalTracks > 0) {
|
if (totalTracks > 0) {
|
||||||
Audio::instance().startBackgroundLoading();
|
sys->startBackgroundLoading();
|
||||||
musicLoadingStarted = true;
|
musicLoadingStarted = true;
|
||||||
} else {
|
} else {
|
||||||
musicLoaded = true;
|
musicLoaded = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
totalTracks = 0;
|
||||||
|
totalLoadingTasks.store(baseTasks + totalTracks);
|
||||||
|
musicLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
pixelFont.init(AssetPath::resolveWithBase(Assets::FONT_ORBITRON), 22);
|
pixelFont.init(AssetPath::resolveWithBase(Assets::FONT_ORBITRON), 22);
|
||||||
loadedTasks.fetch_add(1);
|
loadedTasks.fetch_add(1);
|
||||||
@ -1449,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;
|
||||||
@ -1518,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) {
|
||||||
|
startupFadeActive = false;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
|
||||||
|
if (std::filesystem::exists(introVideoPath)) {
|
||||||
|
state = AppState::Video;
|
||||||
|
} else {
|
||||||
state = AppState::Menu;
|
state = AppState::Menu;
|
||||||
|
}
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1527,9 +1892,10 @@ 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()) {
|
||||||
|
if (sys->isLoadingComplete()) {
|
||||||
musicProgress = 0.7;
|
musicProgress = 0.7;
|
||||||
} else if (Audio::instance().getLoadedTrackCount() > 0) {
|
} else if (sys->getLoadedTrackCount() > 0) {
|
||||||
musicProgress = 0.35;
|
musicProgress = 0.35;
|
||||||
} else {
|
} else {
|
||||||
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
|
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
|
||||||
@ -1540,13 +1906,30 @@ void TetrisApp::Impl::runLoop()
|
|||||||
musicProgress = 0.0;
|
musicProgress = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Uint32 elapsedMs = SDL_GetTicks() - static_cast<Uint32>(loadStart);
|
||||||
|
if (elapsedMs > 1500) {
|
||||||
|
musicProgress = 0.7;
|
||||||
|
musicLoaded = true;
|
||||||
|
} else {
|
||||||
|
musicProgress = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
double timeProgress = std::min(0.1, (now - loadStart) / 500.0);
|
double timeProgress = std::min(0.1, (now - loadStart) / 500.0);
|
||||||
loadingProgress = std::min(1.0, assetProgress + musicProgress + timeProgress);
|
loadingProgress = std::min(1.0, assetProgress + musicProgress + timeProgress);
|
||||||
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) {
|
||||||
|
startupFadeActive = false;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
|
||||||
|
if (std::filesystem::exists(introVideoPath)) {
|
||||||
|
state = AppState::Video;
|
||||||
|
} else {
|
||||||
state = AppState::Menu;
|
state = AppState::Menu;
|
||||||
|
}
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1564,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)");
|
||||||
}
|
}
|
||||||
@ -1573,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;
|
||||||
}
|
}
|
||||||
@ -1584,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;
|
||||||
@ -1613,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;
|
||||||
@ -1915,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);
|
||||||
@ -2308,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);
|
||||||
}
|
}
|
||||||
@ -2341,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.
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
15
src/audio/AudioManager.cpp
Normal file
15
src/audio/AudioManager.cpp
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#include "AudioManager.h"
|
||||||
|
#include "Audio.h"
|
||||||
|
|
||||||
|
static IAudioSystem* g_audioSystem = nullptr;
|
||||||
|
|
||||||
|
IAudioSystem* AudioManager::get() {
|
||||||
|
if (!g_audioSystem) {
|
||||||
|
g_audioSystem = &Audio::instance();
|
||||||
|
}
|
||||||
|
return g_audioSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::set(IAudioSystem* sys) {
|
||||||
|
g_audioSystem = sys;
|
||||||
|
}
|
||||||
11
src/audio/AudioManager.h
Normal file
11
src/audio/AudioManager.h
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../core/interfaces/IAudioSystem.h"
|
||||||
|
|
||||||
|
class AudioManager {
|
||||||
|
public:
|
||||||
|
// Get the currently registered audio system (may return Audio::instance())
|
||||||
|
static IAudioSystem* get();
|
||||||
|
// Replace the audio system (for tests or different backends)
|
||||||
|
static void set(IAudioSystem* sys);
|
||||||
|
};
|
||||||
@ -2,6 +2,7 @@
|
|||||||
#include "SoundEffect.h"
|
#include "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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 (don’t 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)
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
317
src/gameplay/coop/CoopAIController.cpp
Normal file
317
src/gameplay/coop/CoopAIController.cpp
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
#include "CoopAIController.h"
|
||||||
|
|
||||||
|
#include "CoopGame.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
static bool canPlacePieceForSide(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
|
||||||
|
const CoopGame::Piece& p,
|
||||||
|
CoopGame::PlayerSide side) {
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(p, cx, cy)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int bx = p.x + cx;
|
||||||
|
const int by = p.y + cy;
|
||||||
|
|
||||||
|
// Keep the AI strictly in the correct half.
|
||||||
|
if (side == CoopGame::PlayerSide::Right) {
|
||||||
|
if (bx < 10 || bx >= CoopGame::COLS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (bx < 0 || bx >= 10) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above the visible board is allowed.
|
||||||
|
if (by < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (by >= CoopGame::ROWS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (board[by * CoopGame::COLS + bx].occupied) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int dropYFor(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
|
||||||
|
CoopGame::Piece p,
|
||||||
|
CoopGame::PlayerSide side) {
|
||||||
|
// Assumes p is currently placeable.
|
||||||
|
while (true) {
|
||||||
|
CoopGame::Piece next = p;
|
||||||
|
next.y += 1;
|
||||||
|
if (!canPlacePieceForSide(board, next, side)) {
|
||||||
|
return p.y;
|
||||||
|
}
|
||||||
|
p = next;
|
||||||
|
if (p.y > CoopGame::ROWS) {
|
||||||
|
return p.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void applyPiece(std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS>& occ,
|
||||||
|
const CoopGame::Piece& p) {
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(p, cx, cy)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int bx = p.x + cx;
|
||||||
|
const int by = p.y + cy;
|
||||||
|
if (by < 0 || by >= CoopGame::ROWS || bx < 0 || bx >= CoopGame::COLS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
occ[by * CoopGame::COLS + bx] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Eval {
|
||||||
|
double score = -std::numeric_limits<double>::infinity();
|
||||||
|
int rot = 0;
|
||||||
|
int x = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) {
|
||||||
|
const auto& board = game.boardRef();
|
||||||
|
|
||||||
|
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> occ{};
|
||||||
|
for (int i = 0; i < CoopGame::COLS * CoopGame::ROWS; ++i) {
|
||||||
|
occ[i] = board[i].occupied ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoopGame::Piece cur = game.current(side);
|
||||||
|
|
||||||
|
Eval best{};
|
||||||
|
|
||||||
|
// Iterate rotations and x positions. IMPORTANT: allow x to go slightly out of bounds
|
||||||
|
// because our pieces are represented in a 4x4 mask and many rotations have leading
|
||||||
|
// empty columns. For example, placing a vertical I/J/L into column 0 often requires
|
||||||
|
// p.x == -1 or p.x == -2 so the filled cells land at bx==0.
|
||||||
|
// canPlacePieceForSide() enforces the actual half-board bounds.
|
||||||
|
for (int rot = 0; rot < 4; ++rot) {
|
||||||
|
int xmin = (side == CoopGame::PlayerSide::Right) ? 6 : -3;
|
||||||
|
int xmax = (side == CoopGame::PlayerSide::Right) ? 22 : 13;
|
||||||
|
for (int x = xmin; x <= xmax; ++x) {
|
||||||
|
CoopGame::Piece p = cur;
|
||||||
|
p.rot = rot;
|
||||||
|
p.x = x;
|
||||||
|
|
||||||
|
// If this rotation/x is illegal at the current y, try near the top spawn band.
|
||||||
|
if (!canPlacePieceForSide(board, p, side)) {
|
||||||
|
p.y = -2;
|
||||||
|
if (!canPlacePieceForSide(board, p, side)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.y = dropYFor(board, p, side);
|
||||||
|
|
||||||
|
auto occ2 = occ;
|
||||||
|
applyPiece(occ2, p);
|
||||||
|
|
||||||
|
// Count completed full rows (all 20 cols) after placement.
|
||||||
|
int fullRows = 0;
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
bool full = true;
|
||||||
|
for (int cx = 0; cx < CoopGame::COLS; ++cx) {
|
||||||
|
if (!occ2[y * CoopGame::COLS + cx]) {
|
||||||
|
full = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
++fullRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-half column heights + holes + bumpiness.
|
||||||
|
std::array<int, 10> heights{};
|
||||||
|
int aggregateHeight = 0;
|
||||||
|
int holes = 0;
|
||||||
|
|
||||||
|
for (int c = 0; c < 10; ++c) {
|
||||||
|
const int bx = (side == CoopGame::PlayerSide::Right) ? (10 + c) : c;
|
||||||
|
int h = 0;
|
||||||
|
bool found = false;
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
if (occ2[y * CoopGame::COLS + bx]) {
|
||||||
|
h = CoopGame::ROWS - y;
|
||||||
|
found = true;
|
||||||
|
// Count holes below the first filled cell.
|
||||||
|
for (int yy = y + 1; yy < CoopGame::ROWS; ++yy) {
|
||||||
|
if (!occ2[yy * CoopGame::COLS + bx]) {
|
||||||
|
++holes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heights[c] = found ? h : 0;
|
||||||
|
aggregateHeight += heights[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
int bump = 0;
|
||||||
|
for (int i = 0; i < 9; ++i) {
|
||||||
|
bump += std::abs(heights[i] - heights[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward sync potential: rows where the right half is full (10..19).
|
||||||
|
int sideHalfFullRows = 0;
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
bool full = true;
|
||||||
|
int start = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
|
||||||
|
int end = (side == CoopGame::PlayerSide::Right) ? 20 : 10;
|
||||||
|
for (int bx = start; bx < end; ++bx) {
|
||||||
|
if (!occ2[y * CoopGame::COLS + bx]) {
|
||||||
|
full = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
++sideHalfFullRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple heuristic:
|
||||||
|
// - Strongly prefer completed full rows
|
||||||
|
// - Prefer making the right half complete (helps cooperative clears)
|
||||||
|
// - Penalize holes and excessive height/bumpiness
|
||||||
|
double s = 0.0;
|
||||||
|
// Strongly prefer full-line clears across the whole board (rare but best).
|
||||||
|
s += static_cast<double>(fullRows) * 12000.0;
|
||||||
|
// Heavily prefer completing the player's half — make this a primary objective.
|
||||||
|
s += static_cast<double>(sideHalfFullRows) * 6000.0;
|
||||||
|
// Penalize holes and height less aggressively so completing half-rows is prioritized.
|
||||||
|
s -= static_cast<double>(holes) * 180.0;
|
||||||
|
s -= static_cast<double>(aggregateHeight) * 4.0;
|
||||||
|
s -= static_cast<double>(bump) * 10.0;
|
||||||
|
|
||||||
|
// Reduce center bias so edge placements to complete rows are not punished.
|
||||||
|
double centerTarget = (side == CoopGame::PlayerSide::Right) ? 15.0 : 4.5;
|
||||||
|
const double centerBias = -std::abs((x + 1.5) - centerTarget) * 1.0;
|
||||||
|
s += centerBias;
|
||||||
|
|
||||||
|
if (s > best.score) {
|
||||||
|
best.score = s;
|
||||||
|
best.rot = rot;
|
||||||
|
best.x = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void CoopAIController::reset() {
|
||||||
|
m_lastPieceSeq = 0;
|
||||||
|
m_hasPlan = false;
|
||||||
|
m_targetRot = 0;
|
||||||
|
m_targetX = 10;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopAIController::computePlan(const CoopGame& game, CoopGame::PlayerSide side) {
|
||||||
|
const Eval best = evaluateBestPlacementForSide(game, side);
|
||||||
|
m_targetRot = best.rot;
|
||||||
|
m_targetX = best.x;
|
||||||
|
m_hasPlan = true;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopAIController::update(CoopGame& game, CoopGame::PlayerSide side, double frameMs) {
|
||||||
|
const uint64_t seq = game.currentPieceSequence(side);
|
||||||
|
if (seq != m_lastPieceSeq) {
|
||||||
|
m_lastPieceSeq = seq;
|
||||||
|
m_hasPlan = false;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_hasPlan) {
|
||||||
|
computePlan(game, side);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoopGame::Piece cur = game.current(side);
|
||||||
|
|
||||||
|
// Clamp negative deltas (defensive; callers should pass >= 0).
|
||||||
|
const double dt = std::max(0.0, frameMs);
|
||||||
|
|
||||||
|
// Update timers.
|
||||||
|
if (m_moveTimerMs > 0.0) {
|
||||||
|
m_moveTimerMs -= dt;
|
||||||
|
if (m_moveTimerMs < 0.0) m_moveTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
if (m_rotateTimerMs > 0.0) {
|
||||||
|
m_rotateTimerMs -= dt;
|
||||||
|
if (m_rotateTimerMs < 0.0) m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate toward target first.
|
||||||
|
const int curRot = ((cur.rot % 4) + 4) % 4;
|
||||||
|
const int tgtRot = ((m_targetRot % 4) + 4) % 4;
|
||||||
|
int diff = (tgtRot - curRot + 4) % 4;
|
||||||
|
if (diff != 0) {
|
||||||
|
// Human-ish rotation rate limiting.
|
||||||
|
if (m_rotateTimerMs <= 0.0) {
|
||||||
|
const int dir = (diff == 3) ? -1 : 1;
|
||||||
|
game.rotate(side, dir);
|
||||||
|
m_rotateTimerMs = m_rotateIntervalMs;
|
||||||
|
}
|
||||||
|
// While rotating, do not also slide horizontally in the same frame.
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move horizontally toward target.
|
||||||
|
int desiredDir = 0;
|
||||||
|
if (cur.x < m_targetX) desiredDir = +1;
|
||||||
|
else if (cur.x > m_targetX) desiredDir = -1;
|
||||||
|
|
||||||
|
if (desiredDir == 0) {
|
||||||
|
// Aligned: do nothing. Gravity controls fall speed (no AI hard drops).
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DAS/ARR-style horizontal movement pacing.
|
||||||
|
if (m_moveDir != desiredDir) {
|
||||||
|
// New direction / initial press: move immediately, then wait DAS.
|
||||||
|
game.move(side, desiredDir);
|
||||||
|
m_moveDir = desiredDir;
|
||||||
|
m_moveTimerMs = m_dasMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holding direction: repeat every ARR once DAS has elapsed.
|
||||||
|
if (m_moveTimerMs <= 0.0) {
|
||||||
|
game.move(side, desiredDir);
|
||||||
|
m_moveTimerMs = m_arrMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/gameplay/coop/CoopAIController.h
Normal file
36
src/gameplay/coop/CoopAIController.h
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include "CoopGame.h"
|
||||||
|
|
||||||
|
// Minimal, lightweight AI driver for a CoopGame player side (left or right).
|
||||||
|
// It chooses a target rotation/x placement using a simple board heuristic,
|
||||||
|
// then steers the active piece toward that target at a human-like input rate.
|
||||||
|
class CoopAIController {
|
||||||
|
public:
|
||||||
|
CoopAIController() = default;
|
||||||
|
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
// frameMs is the frame delta in milliseconds (same unit used across the gameplay loop).
|
||||||
|
void update(CoopGame& game, CoopGame::PlayerSide side, double frameMs);
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint64_t m_lastPieceSeq = 0;
|
||||||
|
bool m_hasPlan = false;
|
||||||
|
|
||||||
|
int m_targetRot = 0;
|
||||||
|
int m_targetX = 10;
|
||||||
|
|
||||||
|
// Input pacing (ms). These intentionally mirror the defaults used for human input.
|
||||||
|
double m_dasMs = 170.0;
|
||||||
|
double m_arrMs = 40.0;
|
||||||
|
double m_rotateIntervalMs = 110.0;
|
||||||
|
|
||||||
|
// Internal timers/state for rate limiting.
|
||||||
|
double m_moveTimerMs = 0.0;
|
||||||
|
int m_moveDir = 0; // -1, 0, +1
|
||||||
|
double m_rotateTimerMs = 0.0;
|
||||||
|
|
||||||
|
void computePlan(const CoopGame& game, CoopGame::PlayerSide side);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -307,10 +410,9 @@ void CoopGame::spawn(PlayerState& ps) {
|
|||||||
pieceSequence++;
|
pieceSequence++;
|
||||||
if (collides(ps, ps.cur)) {
|
if (collides(ps, ps.cur)) {
|
||||||
ps.toppedOut = true;
|
ps.toppedOut = true;
|
||||||
if (left.toppedOut && right.toppedOut) {
|
// Cooperative mode: game ends when any player tops out.
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
||||||
|
|||||||
@ -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{};
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,9 +249,11 @@ 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
|
||||||
|
|
||||||
|
// Create renderer wrapper
|
||||||
|
auto rwrap = renderer::MakeSDLRenderer(renderer);
|
||||||
// Draw preview fade-out
|
// Draw preview fade-out
|
||||||
if (previewAlpha > 0) {
|
if (previewAlpha > 0) {
|
||||||
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, previewAlpha);
|
if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, previewAlpha);
|
||||||
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;
|
||||||
@ -259,12 +262,12 @@ static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTe
|
|||||||
GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type);
|
GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
|
if (blocksTex) rwrap->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;
|
||||||
}
|
}
|
||||||
@ -950,10 +965,10 @@ void GameRenderer::renderPlayingState(
|
|||||||
// 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, ÷r);
|
rwrap->fillRectF(÷r);
|
||||||
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
59
src/logic/Board.cpp
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
#include "Board.h"
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace logic {
|
||||||
|
|
||||||
|
Board::Board()
|
||||||
|
: grid_(Width * Height, Cell::Empty)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void Board::clear()
|
||||||
|
{
|
||||||
|
std::fill(grid_.begin(), grid_.end(), Cell::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Board::inBounds(int x, int y) const
|
||||||
|
{
|
||||||
|
return x >= 0 && x < Width && y >= 0 && y < Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
Board::Cell Board::at(int x, int y) const
|
||||||
|
{
|
||||||
|
if (!inBounds(x, y)) return Cell::Empty;
|
||||||
|
return grid_[y * Width + x];
|
||||||
|
}
|
||||||
|
|
||||||
|
void Board::set(int x, int y, Cell c)
|
||||||
|
{
|
||||||
|
if (!inBounds(x, y)) return;
|
||||||
|
grid_[y * Width + x] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Board::clearFullLines()
|
||||||
|
{
|
||||||
|
int cleared = 0;
|
||||||
|
// scan from bottom to top
|
||||||
|
for (int y = Height - 1; y >= 0; --y) {
|
||||||
|
bool full = true;
|
||||||
|
for (int x = 0; x < Width; ++x) {
|
||||||
|
if (at(x, y) == Cell::Empty) { full = false; break; }
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
// remove row y: move all rows above down by one
|
||||||
|
for (int yy = y; yy > 0; --yy) {
|
||||||
|
for (int x = 0; x < Width; ++x) {
|
||||||
|
grid_[yy * Width + x] = grid_[(yy - 1) * Width + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// clear top row
|
||||||
|
for (int x = 0; x < Width; ++x) grid_[x] = Cell::Empty;
|
||||||
|
++cleared;
|
||||||
|
// stay on same y to re-check the row that fell into place
|
||||||
|
++y; // because next iteration decrements y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace logic
|
||||||
32
src/logic/Board.h
Normal file
32
src/logic/Board.h
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace logic {
|
||||||
|
|
||||||
|
class Board {
|
||||||
|
public:
|
||||||
|
static constexpr int Width = 10;
|
||||||
|
static constexpr int Height = 20;
|
||||||
|
|
||||||
|
enum class Cell : uint8_t { Empty = 0, Filled = 1 };
|
||||||
|
|
||||||
|
Board();
|
||||||
|
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
Cell at(int x, int y) const;
|
||||||
|
void set(int x, int y, Cell c);
|
||||||
|
bool inBounds(int x, int y) const;
|
||||||
|
|
||||||
|
// Remove and return number of full lines cleared. Rows above fall down.
|
||||||
|
int clearFullLines();
|
||||||
|
|
||||||
|
const std::vector<Cell>& data() const { return grid_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<Cell> grid_; // row-major: y*Width + x
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace logic
|
||||||
21
src/network/CoopNetButtons.h
Normal file
21
src/network/CoopNetButtons.h
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace coopnet {
|
||||||
|
// 8-bit input mask carried in NetSession::InputFrame.
|
||||||
|
// Keep in sync across capture/apply on both peers.
|
||||||
|
enum Buttons : uint8_t {
|
||||||
|
MoveLeft = 1u << 0,
|
||||||
|
MoveRight = 1u << 1,
|
||||||
|
SoftDrop = 1u << 2,
|
||||||
|
RotCW = 1u << 3,
|
||||||
|
RotCCW = 1u << 4,
|
||||||
|
HardDrop = 1u << 5,
|
||||||
|
Hold = 1u << 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool has(uint8_t mask, Buttons b) {
|
||||||
|
return (mask & static_cast<uint8_t>(b)) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/network/NetSession.cpp
Normal file
324
src/network/NetSession.cpp
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
#include "NetSession.h"
|
||||||
|
|
||||||
|
#include <enet/enet.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint8_t kChannelReliable = 0;
|
||||||
|
|
||||||
|
static bool netLogVerboseEnabled() {
|
||||||
|
// Set environment variable / hint: SPACETRIS_NET_LOG=1
|
||||||
|
const char* v = SDL_GetHint("SPACETRIS_NET_LOG");
|
||||||
|
return v && v[0] == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
static void append(std::vector<uint8_t>& out, const T& value) {
|
||||||
|
const uint8_t* p = reinterpret_cast<const uint8_t*>(&value);
|
||||||
|
out.insert(out.end(), p, p + sizeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
static bool read(const uint8_t* data, size_t size, size_t& off, T& out) {
|
||||||
|
if (off + sizeof(T) > size) return false;
|
||||||
|
std::memcpy(&out, data + off, sizeof(T));
|
||||||
|
off += sizeof(T);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NetSession::NetSession() = default;
|
||||||
|
|
||||||
|
NetSession::~NetSession() {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::ensureEnetInitialized() {
|
||||||
|
static bool s_inited = false;
|
||||||
|
if (s_inited) return true;
|
||||||
|
if (enet_initialize() != 0) {
|
||||||
|
setError("enet_initialize failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s_inited = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::setError(const std::string& msg) {
|
||||||
|
m_lastError = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::host(const std::string& bindHost, uint16_t port) {
|
||||||
|
shutdown();
|
||||||
|
if (!ensureEnetInitialized()) return false;
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] host(bind='%s', port=%u)", bindHost.c_str(), (unsigned)port);
|
||||||
|
|
||||||
|
ENetAddress address{};
|
||||||
|
address.host = ENET_HOST_ANY;
|
||||||
|
address.port = port;
|
||||||
|
|
||||||
|
if (!bindHost.empty() && bindHost != "0.0.0.0") {
|
||||||
|
if (enet_address_set_host(&address, bindHost.c_str()) != 0) {
|
||||||
|
setError("enet_address_set_host (bind) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 peer, 2 channels (reserve extra)
|
||||||
|
m_host = enet_host_create(&address, 1, 2, 0, 0);
|
||||||
|
if (!m_host) {
|
||||||
|
setError("enet_host_create (host) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::Host;
|
||||||
|
m_state = ConnState::Connecting;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::join(const std::string& hostNameOrIp, uint16_t port) {
|
||||||
|
shutdown();
|
||||||
|
if (!ensureEnetInitialized()) return false;
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] join(remote='%s', port=%u)", hostNameOrIp.c_str(), (unsigned)port);
|
||||||
|
|
||||||
|
m_host = enet_host_create(nullptr, 1, 2, 0, 0);
|
||||||
|
if (!m_host) {
|
||||||
|
setError("enet_host_create (client) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ENetAddress address{};
|
||||||
|
if (enet_address_set_host(&address, hostNameOrIp.c_str()) != 0) {
|
||||||
|
setError("enet_address_set_host failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
address.port = port;
|
||||||
|
|
||||||
|
m_peer = enet_host_connect(m_host, &address, 2, 0);
|
||||||
|
if (!m_peer) {
|
||||||
|
setError("enet_host_connect failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::Client;
|
||||||
|
m_state = ConnState::Connecting;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::shutdown() {
|
||||||
|
if (m_host || m_peer) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] shutdown(mode=%d state=%d)", (int)m_mode, (int)m_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remoteInputs.clear();
|
||||||
|
m_remoteHashes.clear();
|
||||||
|
m_receivedHandshake.reset();
|
||||||
|
|
||||||
|
m_inputsSent = 0;
|
||||||
|
m_inputsReceived = 0;
|
||||||
|
m_hashesSent = 0;
|
||||||
|
m_hashesReceived = 0;
|
||||||
|
m_handshakesSent = 0;
|
||||||
|
m_handshakesReceived = 0;
|
||||||
|
m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||||
|
m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||||
|
m_lastStatsLogMs = 0;
|
||||||
|
|
||||||
|
if (m_peer) {
|
||||||
|
enet_peer_disconnect(m_peer, 0);
|
||||||
|
m_peer = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_host) {
|
||||||
|
enet_host_destroy(m_host);
|
||||||
|
m_host = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::None;
|
||||||
|
m_state = ConnState::Disconnected;
|
||||||
|
m_lastError.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::poll(uint32_t timeoutMs) {
|
||||||
|
if (!m_host) return;
|
||||||
|
|
||||||
|
ENetEvent event{};
|
||||||
|
while (enet_host_service(m_host, &event, static_cast<enet_uint32>(timeoutMs)) > 0) {
|
||||||
|
switch (event.type) {
|
||||||
|
case ENET_EVENT_TYPE_CONNECT:
|
||||||
|
m_peer = event.peer;
|
||||||
|
m_state = ConnState::Connected;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] connected (mode=%d)", (int)m_mode);
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_RECEIVE:
|
||||||
|
if (event.packet) {
|
||||||
|
handlePacket(event.packet->data, event.packet->dataLength);
|
||||||
|
enet_packet_destroy(event.packet);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_DISCONNECT:
|
||||||
|
m_peer = nullptr;
|
||||||
|
m_state = ConnState::Disconnected;
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET] disconnected");
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_NONE:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After first event, do non-blocking passes.
|
||||||
|
timeoutMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limited stats log (opt-in)
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
const uint32_t nowMs = SDL_GetTicks();
|
||||||
|
if (m_lastStatsLogMs == 0) m_lastStatsLogMs = nowMs;
|
||||||
|
if (nowMs - m_lastStatsLogMs >= 1000u) {
|
||||||
|
m_lastStatsLogMs = nowMs;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET] stats: sent(in=%u hash=%u hs=%u) recv(in=%u hash=%u hs=%u) lastRecv(inTick=%u hashTick=%u) state=%d",
|
||||||
|
m_inputsSent,
|
||||||
|
m_hashesSent,
|
||||||
|
m_handshakesSent,
|
||||||
|
m_inputsReceived,
|
||||||
|
m_hashesReceived,
|
||||||
|
m_handshakesReceived,
|
||||||
|
m_lastRecvInputTick,
|
||||||
|
m_lastRecvHashTick,
|
||||||
|
(int)m_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendBytesReliable(const void* data, size_t size) {
|
||||||
|
if (!m_peer) return false;
|
||||||
|
ENetPacket* packet = enet_packet_create(data, size, ENET_PACKET_FLAG_RELIABLE);
|
||||||
|
if (!packet) return false;
|
||||||
|
if (enet_peer_send(m_peer, kChannelReliable, packet) != 0) {
|
||||||
|
enet_packet_destroy(packet);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Let the caller decide flush cadence; but for tiny control packets, flushing is cheap.
|
||||||
|
enet_host_flush(m_host);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendHandshake(const Handshake& hs) {
|
||||||
|
if (m_mode != Mode::Host) return false;
|
||||||
|
m_handshakesSent++;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Handshake));
|
||||||
|
append(buf, hs.rngSeed);
|
||||||
|
append(buf, hs.startTick);
|
||||||
|
append(buf, hs.startLevel);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<NetSession::Handshake> NetSession::takeReceivedHandshake() {
|
||||||
|
auto out = m_receivedHandshake;
|
||||||
|
m_receivedHandshake.reset();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendLocalInput(uint32_t tick, uint8_t buttons) {
|
||||||
|
m_inputsSent++;
|
||||||
|
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||||
|
}
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Input));
|
||||||
|
append(buf, tick);
|
||||||
|
append(buf, buttons);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint8_t> NetSession::getRemoteButtons(uint32_t tick) const {
|
||||||
|
auto it = m_remoteInputs.find(tick);
|
||||||
|
if (it == m_remoteInputs.end()) return std::nullopt;
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendStateHash(uint32_t tick, uint64_t hash) {
|
||||||
|
m_hashesSent++;
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||||
|
}
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Hash));
|
||||||
|
append(buf, tick);
|
||||||
|
append(buf, hash);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint64_t> NetSession::takeRemoteHash(uint32_t tick) {
|
||||||
|
auto it = m_remoteHashes.find(tick);
|
||||||
|
if (it == m_remoteHashes.end()) return std::nullopt;
|
||||||
|
uint64_t v = it->second;
|
||||||
|
m_remoteHashes.erase(it);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::handlePacket(const uint8_t* data, size_t size) {
|
||||||
|
if (!data || size < 1) return;
|
||||||
|
size_t off = 0;
|
||||||
|
uint8_t typeByte = 0;
|
||||||
|
if (!read(data, size, off, typeByte)) return;
|
||||||
|
|
||||||
|
MsgType t = static_cast<MsgType>(typeByte);
|
||||||
|
switch (t) {
|
||||||
|
case MsgType::Handshake: {
|
||||||
|
Handshake hs{};
|
||||||
|
if (!read(data, size, off, hs.rngSeed)) return;
|
||||||
|
if (!read(data, size, off, hs.startTick)) return;
|
||||||
|
if (!read(data, size, off, hs.startLevel)) return;
|
||||||
|
m_receivedHandshake = hs;
|
||||||
|
m_handshakesReceived++;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MsgType::Input: {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint8_t buttons = 0;
|
||||||
|
if (!read(data, size, off, tick)) return;
|
||||||
|
if (!read(data, size, off, buttons)) return;
|
||||||
|
m_remoteInputs[tick] = buttons;
|
||||||
|
m_inputsReceived++;
|
||||||
|
m_lastRecvInputTick = tick;
|
||||||
|
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MsgType::Hash: {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint64_t hash = 0;
|
||||||
|
if (!read(data, size, off, tick)) return;
|
||||||
|
if (!read(data, size, off, hash)) return;
|
||||||
|
m_remoteHashes[tick] = hash;
|
||||||
|
m_hashesReceived++;
|
||||||
|
m_lastRecvHashTick = tick;
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/network/NetSession.h
Normal file
118
src/network/NetSession.h
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct _ENetHost;
|
||||||
|
struct _ENetPeer;
|
||||||
|
|
||||||
|
// Lockstep networking session for COOPERATE (network) mode.
|
||||||
|
//
|
||||||
|
// Design goals:
|
||||||
|
// - Non-blocking polling (caller drives poll from the main loop)
|
||||||
|
// - Reliable, ordered delivery for inputs and control messages
|
||||||
|
// - Host provides seed + start tick (handshake)
|
||||||
|
// - Only inputs/state hashes are exchanged (no board sync)
|
||||||
|
class NetSession {
|
||||||
|
public:
|
||||||
|
enum class Mode {
|
||||||
|
None,
|
||||||
|
Host,
|
||||||
|
Client,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ConnState {
|
||||||
|
Disconnected,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Handshake {
|
||||||
|
uint32_t rngSeed = 0;
|
||||||
|
uint32_t startTick = 0;
|
||||||
|
uint8_t startLevel = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct InputFrame {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint8_t buttons = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
NetSession();
|
||||||
|
~NetSession();
|
||||||
|
|
||||||
|
NetSession(const NetSession&) = delete;
|
||||||
|
NetSession& operator=(const NetSession&) = delete;
|
||||||
|
|
||||||
|
// If bindHost is empty or "0.0.0.0", binds to ENET_HOST_ANY.
|
||||||
|
bool host(const std::string& bindHost, uint16_t port);
|
||||||
|
bool join(const std::string& hostNameOrIp, uint16_t port);
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
void poll(uint32_t timeoutMs = 0);
|
||||||
|
|
||||||
|
Mode mode() const { return m_mode; }
|
||||||
|
ConnState state() const { return m_state; }
|
||||||
|
bool isConnected() const { return m_state == ConnState::Connected; }
|
||||||
|
|
||||||
|
// Host-only: send handshake once the peer connects.
|
||||||
|
bool sendHandshake(const Handshake& hs);
|
||||||
|
|
||||||
|
// Client-only: becomes available once received from host.
|
||||||
|
std::optional<Handshake> takeReceivedHandshake();
|
||||||
|
|
||||||
|
// Input exchange --------------------------------------------------------
|
||||||
|
// Send local input for a given simulation tick.
|
||||||
|
bool sendLocalInput(uint32_t tick, uint8_t buttons);
|
||||||
|
|
||||||
|
// Returns the last received remote input for a tick (if any).
|
||||||
|
std::optional<uint8_t> getRemoteButtons(uint32_t tick) const;
|
||||||
|
|
||||||
|
// Hash exchange (for desync detection) ---------------------------------
|
||||||
|
bool sendStateHash(uint32_t tick, uint64_t hash);
|
||||||
|
std::optional<uint64_t> takeRemoteHash(uint32_t tick);
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
std::string lastError() const { return m_lastError; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class MsgType : uint8_t {
|
||||||
|
Handshake = 1,
|
||||||
|
Input = 2,
|
||||||
|
Hash = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ensureEnetInitialized();
|
||||||
|
void setError(const std::string& msg);
|
||||||
|
|
||||||
|
bool sendBytesReliable(const void* data, size_t size);
|
||||||
|
void handlePacket(const uint8_t* data, size_t size);
|
||||||
|
|
||||||
|
Mode m_mode = Mode::None;
|
||||||
|
ConnState m_state = ConnState::Disconnected;
|
||||||
|
|
||||||
|
_ENetHost* m_host = nullptr;
|
||||||
|
_ENetPeer* m_peer = nullptr;
|
||||||
|
|
||||||
|
std::string m_lastError;
|
||||||
|
|
||||||
|
std::optional<Handshake> m_receivedHandshake;
|
||||||
|
|
||||||
|
std::unordered_map<uint32_t, uint8_t> m_remoteInputs;
|
||||||
|
std::unordered_map<uint32_t, uint64_t> m_remoteHashes;
|
||||||
|
|
||||||
|
// Debug logging (rate-limited)
|
||||||
|
uint32_t m_inputsSent = 0;
|
||||||
|
uint32_t m_inputsReceived = 0;
|
||||||
|
uint32_t m_hashesSent = 0;
|
||||||
|
uint32_t m_hashesReceived = 0;
|
||||||
|
uint32_t m_handshakesSent = 0;
|
||||||
|
uint32_t m_handshakesReceived = 0;
|
||||||
|
uint32_t m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||||
|
uint32_t m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||||
|
uint32_t m_lastStatsLogMs = 0;
|
||||||
|
};
|
||||||
38
src/renderer/Renderer.h
Normal file
38
src/renderer/Renderer.h
Normal 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
|
||||||
|
|
||||||
27
src/renderer/Renderer_iface.h
Normal file
27
src/renderer/Renderer_iface.h
Normal 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
|
||||||
80
src/renderer/SDLRenderer.cpp
Normal file
80
src/renderer/SDLRenderer.cpp
Normal 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
|
||||||
41
src/resources/ResourceManager.cpp
Normal file
41
src/resources/ResourceManager.cpp
Normal 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
|
||||||
43
src/resources/ResourceManager.h
Normal file
43
src/resources/ResourceManager.h
Normal 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
|
||||||
@ -1,19 +1,24 @@
|
|||||||
#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_surface.h>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#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
|
||||||
@ -110,6 +115,78 @@ static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP
|
|||||||
|
|
||||||
MenuState::MenuState(StateContext& ctx) : State(ctx) {}
|
MenuState::MenuState(StateContext& ctx) : State(ctx) {}
|
||||||
|
|
||||||
|
void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
|
||||||
|
if (show) {
|
||||||
|
if (!coopSetupVisible && !coopSetupAnimating) {
|
||||||
|
// Avoid overlapping panels
|
||||||
|
if (aboutPanelVisible && !aboutPanelAnimating) {
|
||||||
|
aboutPanelAnimating = true;
|
||||||
|
aboutDirection = -1;
|
||||||
|
}
|
||||||
|
if (helpPanelVisible && !helpPanelAnimating) {
|
||||||
|
helpPanelAnimating = true;
|
||||||
|
helpDirection = -1;
|
||||||
|
}
|
||||||
|
if (optionsVisible && !optionsAnimating) {
|
||||||
|
optionsAnimating = true;
|
||||||
|
optionsDirection = -1;
|
||||||
|
}
|
||||||
|
if (levelPanelVisible && !levelPanelAnimating) {
|
||||||
|
levelPanelAnimating = true;
|
||||||
|
levelDirection = -1;
|
||||||
|
}
|
||||||
|
if (exitPanelVisible && !exitPanelAnimating) {
|
||||||
|
exitPanelAnimating = true;
|
||||||
|
exitDirection = -1;
|
||||||
|
if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
coopSetupAnimating = true;
|
||||||
|
coopSetupDirection = 1;
|
||||||
|
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;
|
||||||
|
selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate);
|
||||||
|
// Ensure the transition value is non-zero so render code can show
|
||||||
|
// the inline choice buttons immediately on the same frame.
|
||||||
|
if (coopSetupTransition <= 0.0) coopSetupTransition = 0.001;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (coopSetupVisible && !coopSetupAnimating) {
|
||||||
|
coopSetupAnimating = true;
|
||||||
|
coopSetupDirection = -1;
|
||||||
|
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)
|
||||||
|
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
|
||||||
|
if (auto sys = AudioManager::get()) sys->playMenuMusic();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MenuState::showHelpPanel(bool show) {
|
void MenuState::showHelpPanel(bool show) {
|
||||||
if (show) {
|
if (show) {
|
||||||
if (!helpPanelVisible && !helpPanelAnimating) {
|
if (!helpPanelVisible && !helpPanelAnimating) {
|
||||||
@ -204,10 +281,11 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale,
|
|||||||
};
|
};
|
||||||
|
|
||||||
int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0;
|
int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0;
|
||||||
ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel);
|
const bool coopVsAI = ctx.coopVsAI ? *ctx.coopVsAI : false;
|
||||||
|
ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel, coopVsAI);
|
||||||
|
|
||||||
const int hovered = (ctx.hoveredButton ? *ctx.hoveredButton : -1);
|
const int hovered = (ctx.hoveredButton ? *ctx.hoveredButton : -1);
|
||||||
const double baseAlpha = 1.0;
|
const double baseAlpha = 1.0; // Base alpha for button rendering
|
||||||
// Pulse is encoded as a signed delta so PLAY can dim/brighten while focused.
|
// Pulse is encoded as a signed delta so PLAY can dim/brighten while focused.
|
||||||
const double pulseDelta = (buttonPulseAlpha - 1.0);
|
const double pulseDelta = (buttonPulseAlpha - 1.0);
|
||||||
const double flashDelta = buttonFlash * buttonFlashAmount;
|
const double flashDelta = buttonFlash * buttonFlashAmount;
|
||||||
@ -225,9 +303,251 @@ void MenuState::onExit() {
|
|||||||
if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; }
|
if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; }
|
||||||
if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; }
|
if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; }
|
||||||
if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; }
|
if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; }
|
||||||
|
if (coopInfoTexture) { SDL_DestroyTexture(coopInfoTexture); coopInfoTexture = nullptr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
// 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.
|
||||||
|
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) {
|
||||||
|
// Coop setup panel navigation (modal within the menu)
|
||||||
|
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_A:
|
||||||
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
|
// 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;
|
||||||
|
case SDL_SCANCODE_RIGHT:
|
||||||
|
case SDL_SCANCODE_D:
|
||||||
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
|
coopSetupSelected = (coopSetupSelected + 1) % 3;
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||||||
|
coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2;
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
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_KP_ENTER:
|
||||||
|
case SDL_SCANCODE_SPACE:
|
||||||
|
{
|
||||||
|
// Existing flows (Local 2P / AI) are preserved exactly.
|
||||||
|
if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) {
|
||||||
|
const bool useAI = (coopSetupSelected == 1);
|
||||||
|
if (ctx.coopVsAI) {
|
||||||
|
*ctx.coopVsAI = useAI;
|
||||||
|
}
|
||||||
|
if (ctx.game) {
|
||||||
|
ctx.game->setMode(GameMode::Cooperate);
|
||||||
|
ctx.game->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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network flow (new): choose host/join, confirm connection before starting.
|
||||||
|
if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) {
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||||||
|
coopNetworkRoleSelected = 0;
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
if (coopNetworkSession) {
|
||||||
|
coopNetworkSession->shutdown();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Allow other keys, but don't let them affect the main menu while coop is open.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse input for COOP setup panel or inline coop buttons
|
||||||
|
if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) {
|
||||||
|
if (coopSetupRectsValid) {
|
||||||
|
// While the coop submenu is active (animating or visible) we disallow
|
||||||
|
// mouse interaction — only keyboard LEFT/RIGHT/ESC is permitted.
|
||||||
|
if (coopSetupAnimating || coopSetupVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float mx = static_cast<float>(e.button.x);
|
||||||
|
const float my = static_cast<float>(e.button.y);
|
||||||
|
if (mx >= lastLogicalVP.x && my >= lastLogicalVP.y && mx <= (lastLogicalVP.x + lastLogicalVP.w) && my <= (lastLogicalVP.y + lastLogicalVP.h)) {
|
||||||
|
const float lx = (mx - lastLogicalVP.x) / std::max(0.0001f, lastLogicalScale);
|
||||||
|
const float ly = (my - lastLogicalVP.y) / std::max(0.0001f, lastLogicalScale);
|
||||||
|
|
||||||
|
auto hit = [&](const SDL_FRect& r) {
|
||||||
|
return lx >= r.x && lx <= (r.x + r.w) && ly >= r.y && ly <= (r.y + r.h);
|
||||||
|
};
|
||||||
|
|
||||||
|
int chosen = -1;
|
||||||
|
if (hit(coopSetupBtnRects[0])) chosen = 0;
|
||||||
|
else if (hit(coopSetupBtnRects[1])) chosen = 1;
|
||||||
|
|
||||||
|
if (chosen != -1) {
|
||||||
|
coopSetupSelected = chosen;
|
||||||
|
const bool useAI = (coopSetupSelected == 1);
|
||||||
|
if (ctx.coopVsAI) {
|
||||||
|
*ctx.coopVsAI = useAI;
|
||||||
|
}
|
||||||
|
if (ctx.game) {
|
||||||
|
ctx.game->setMode(GameMode::Cooperate);
|
||||||
|
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
}
|
||||||
|
if (ctx.coopGame) {
|
||||||
|
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
}
|
||||||
|
showCoopSetupPanel(false);
|
||||||
|
if (ctx.startPlayTransition) {
|
||||||
|
ctx.startPlayTransition();
|
||||||
|
} else if (ctx.stateManager) {
|
||||||
|
ctx.stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard navigation for menu buttons
|
// Keyboard navigation for menu buttons
|
||||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||||
// When the player uses the keyboard, don't let an old mouse hover keep focus on a button.
|
// When the player uses the keyboard, don't let an old mouse hover keep focus on a button.
|
||||||
@ -290,8 +610,21 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case SDL_SCANCODE_ESCAPE:
|
case SDL_SCANCODE_ESCAPE:
|
||||||
// Close HUD
|
showCoopSetupPanel(false, true);
|
||||||
exitPanelAnimating = true; exitDirection = -1;
|
// Cannot print std::function as a pointer; print presence (1/0) instead
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop ENTER pressed, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager);
|
||||||
|
if (ctx.startPlayTransition) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: calling startPlayTransition");
|
||||||
|
ctx.startPlayTransition();
|
||||||
|
} else {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: startPlayTransition is null");
|
||||||
|
}
|
||||||
|
if (ctx.stateManager) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: setting AppState::Playing on stateManager");
|
||||||
|
ctx.stateManager->setState(AppState::Playing);
|
||||||
|
} else {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: stateManager is null");
|
||||||
|
}
|
||||||
if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false;
|
if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false;
|
||||||
return;
|
return;
|
||||||
case SDL_SCANCODE_PAGEDOWN:
|
case SDL_SCANCODE_PAGEDOWN:
|
||||||
@ -457,6 +790,49 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coop setup panel navigation (modal within the menu)
|
||||||
|
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0) {
|
||||||
|
switch (e.key.scancode) {
|
||||||
|
case SDL_SCANCODE_LEFT:
|
||||||
|
coopSetupSelected = 0;
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
|
case SDL_SCANCODE_RIGHT:
|
||||||
|
coopSetupSelected = 1;
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
|
// Explicitly consume Up/Down so main menu navigation doesn't trigger
|
||||||
|
case SDL_SCANCODE_UP:
|
||||||
|
case SDL_SCANCODE_DOWN:
|
||||||
|
return;
|
||||||
|
case SDL_SCANCODE_ESCAPE:
|
||||||
|
// Close coop panel without restarting music
|
||||||
|
showCoopSetupPanel(false, false);
|
||||||
|
return;
|
||||||
|
case SDL_SCANCODE_RETURN:
|
||||||
|
case SDL_SCANCODE_KP_ENTER:
|
||||||
|
case SDL_SCANCODE_SPACE:
|
||||||
|
{
|
||||||
|
const bool useAI = (coopSetupSelected == 1);
|
||||||
|
if (ctx.coopVsAI) {
|
||||||
|
*ctx.coopVsAI = useAI;
|
||||||
|
}
|
||||||
|
if (ctx.game) {
|
||||||
|
ctx.game->setMode(GameMode::Cooperate);
|
||||||
|
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
}
|
||||||
|
if (ctx.coopGame) {
|
||||||
|
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
}
|
||||||
|
showCoopSetupPanel(false, false);
|
||||||
|
triggerPlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.key.scancode) {
|
switch (e.key.scancode) {
|
||||||
case SDL_SCANCODE_LEFT:
|
case SDL_SCANCODE_LEFT:
|
||||||
case SDL_SCANCODE_UP:
|
case SDL_SCANCODE_UP:
|
||||||
@ -489,15 +865,8 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
triggerPlay();
|
triggerPlay();
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
// Cooperative play
|
// Cooperative play: open setup panel (2P vs AI)
|
||||||
if (ctx.game) {
|
showCoopSetupPanel(true);
|
||||||
ctx.game->setMode(GameMode::Cooperate);
|
|
||||||
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
|
||||||
}
|
|
||||||
if (ctx.coopGame) {
|
|
||||||
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
|
||||||
}
|
|
||||||
triggerPlay();
|
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
// Start challenge run at level 1
|
// Start challenge run at level 1
|
||||||
@ -566,6 +935,10 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case SDL_SCANCODE_ESCAPE:
|
case SDL_SCANCODE_ESCAPE:
|
||||||
|
if (coopSetupVisible && !coopSetupAnimating) {
|
||||||
|
showCoopSetupPanel(false, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If options panel is visible, hide it first.
|
// If options panel is visible, hide it first.
|
||||||
if (optionsVisible && !optionsAnimating) {
|
if (optionsVisible && !optionsAnimating) {
|
||||||
optionsAnimating = true;
|
optionsAnimating = true;
|
||||||
@ -588,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
|
||||||
@ -665,6 +1047,21 @@ void MenuState::update(double frameMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advance coop setup panel animation if active
|
||||||
|
if (coopSetupAnimating) {
|
||||||
|
double delta = (frameMs / coopSetupTransitionDurationMs) * static_cast<double>(coopSetupDirection);
|
||||||
|
coopSetupTransition += delta;
|
||||||
|
if (coopSetupTransition >= 1.0) {
|
||||||
|
coopSetupTransition = 1.0;
|
||||||
|
coopSetupVisible = true;
|
||||||
|
coopSetupAnimating = false;
|
||||||
|
} else if (coopSetupTransition <= 0.0) {
|
||||||
|
coopSetupTransition = 0.0;
|
||||||
|
coopSetupVisible = false;
|
||||||
|
coopSetupAnimating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Animate level selection highlight position toward the selected cell center
|
// Animate level selection highlight position toward the selected cell center
|
||||||
if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) {
|
if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) {
|
||||||
// Recompute same grid geometry used in render to find target center
|
// Recompute same grid geometry used in render to find target center
|
||||||
@ -790,6 +1187,8 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
const float moveAmount = 420.0f; // increased so lower score rows slide further up
|
const float moveAmount = 420.0f; // increased so lower score rows slide further up
|
||||||
|
|
||||||
// Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown.
|
// Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown.
|
||||||
|
// Exclude coopSetupTransition from the highscores slide so opening the
|
||||||
|
// COOPERATE setup does not shift the highscores panel upward.
|
||||||
float combinedTransition = static_cast<float>(std::max(
|
float combinedTransition = static_cast<float>(std::max(
|
||||||
std::max(std::max(optionsTransition, levelTransition), exitTransition),
|
std::max(std::max(optionsTransition, levelTransition), exitTransition),
|
||||||
std::max(helpTransition, aboutTransition)
|
std::max(helpTransition, aboutTransition)
|
||||||
@ -823,22 +1222,35 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small TOP PLAYER label under the logo
|
// Small label under the logo — show "COOPERATE" when coop setup is active
|
||||||
const std::string smallTitle = "TOP PLAYER";
|
const std::string smallTitle = (coopSetupAnimating || coopSetupVisible) ? "COOPERATE" : "TOP PLAYER";
|
||||||
float titleScale = 0.9f;
|
float titleScale = 0.9f;
|
||||||
int tW = 0, tH = 0;
|
int tW = 0, tH = 0;
|
||||||
useFont->measure(smallTitle, titleScale, tW, tH);
|
useFont->measure(smallTitle, titleScale, tW, tH);
|
||||||
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());
|
||||||
@ -848,7 +1260,9 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10
|
size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10
|
||||||
|
|
||||||
// Draw highscores as an inline HUD-like panel (no opaque box), matching Options/Level/Exit style
|
// Draw highscores as an inline HUD-like panel (no opaque box), matching Options/Level/Exit style
|
||||||
if (useFont) {
|
// Keep highscores visible while the coop setup is animating; hide them only
|
||||||
|
// once the coop setup is fully visible so the buttons can appear afterward.
|
||||||
|
if (useFont && !coopSetupVisible) {
|
||||||
const float panelW = (wantedType == "cooperate") ? std::min(920.0f, LOGICAL_W * 0.92f) : std::min(780.0f, LOGICAL_W * 0.85f);
|
const float panelW = (wantedType == "cooperate") ? std::min(920.0f, LOGICAL_W * 0.92f) : std::min(780.0f, LOGICAL_W * 0.85f);
|
||||||
const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows
|
const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows
|
||||||
// Shift the entire highscores panel slightly left (~1.5% of logical width)
|
// Shift the entire highscores panel slightly left (~1.5% of logical width)
|
||||||
@ -1112,6 +1526,286 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline COOP choice buttons: when COOPERATE is selected show two large
|
||||||
|
// choice buttons in the highscores panel area (top of the screen).
|
||||||
|
// coopSetupRectsValid is cleared each frame and set to true when buttons are drawn
|
||||||
|
coopSetupRectsValid = false;
|
||||||
|
// Draw the inline COOP choice buttons as soon as the coop setup starts
|
||||||
|
// animating or is visible. Highscores are no longer slid upward when
|
||||||
|
// the setup opens, so the buttons can show immediately.
|
||||||
|
if (coopSetupAnimating || coopSetupVisible) {
|
||||||
|
// Recompute panel geometry matching highscores layout above so buttons
|
||||||
|
// appear centered inside the same visual area.
|
||||||
|
const float panelW = std::min(920.0f, LOGICAL_W * 0.92f);
|
||||||
|
const float panelShift = LOGICAL_W * 0.015f;
|
||||||
|
const float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX - panelShift;
|
||||||
|
const float panelH = 36.0f + maxDisplay * 36.0f; // same as highscores panel
|
||||||
|
// Highscores are animated upward by `panelDelta` while opening the coop setup.
|
||||||
|
// We want the choice buttons to appear *after* that scroll, in the original
|
||||||
|
// highscores area (not sliding offscreen with the scores).
|
||||||
|
const float panelBaseY = scoresStartY - 20.0f;
|
||||||
|
|
||||||
|
// Choice buttons (partner selection) and nested network host/join UI
|
||||||
|
const float btnH2 = 60.0f;
|
||||||
|
const float gap = 34.0f;
|
||||||
|
const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f);
|
||||||
|
const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f;
|
||||||
|
// 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
|
||||||
|
const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f;
|
||||||
|
|
||||||
|
coopSetupBtnRects[0] = SDL_FRect{ bx, 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;
|
||||||
|
|
||||||
|
SDL_Color bg{ 24, 36, 52, 220 };
|
||||||
|
SDL_Color border{ 110, 200, 255, 220 };
|
||||||
|
|
||||||
|
// Load coop info image once when the coop setup is first shown
|
||||||
|
if (!coopInfoTexture) {
|
||||||
|
const std::string resolved = AssetPath::resolveImagePath("assets/images/cooperate_info.png");
|
||||||
|
if (!resolved.empty()) {
|
||||||
|
SDL_Surface* surf = IMG_Load(resolved.c_str());
|
||||||
|
if (surf) {
|
||||||
|
// Save dimensions from surface then create texture
|
||||||
|
coopInfoTexW = surf->w;
|
||||||
|
coopInfoTexH = surf->h;
|
||||||
|
coopInfoTexture = SDL_CreateTextureFromSurface(renderer, surf);
|
||||||
|
SDL_DestroySurface(surf);
|
||||||
|
} else {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "MenuState: failed to load %s: %s", resolved.c_str(), SDL_GetError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
float alphaFactor = static_cast<float>(coopSetupTransition);
|
||||||
|
if (alphaFactor < 0.0f) alphaFactor = 0.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) {
|
||||||
|
float totalW = totalChoiceW;
|
||||||
|
// Keep coop info image slightly smaller than the button row.
|
||||||
|
// Use a modest scale so it doesn't dominate the UI.
|
||||||
|
float maxImgW = totalW * 0.65f;
|
||||||
|
targetW = std::min(maxImgW, static_cast<float>(coopInfoTexW));
|
||||||
|
float scale = targetW / static_cast<float>(coopInfoTexW);
|
||||||
|
targetH = static_cast<float>(coopInfoTexH) * scale;
|
||||||
|
imgX = bx + (totalW - targetW) * 0.5f;
|
||||||
|
imgY = by - targetH - 8.0f; // keep the small gap above buttons
|
||||||
|
float minY = panelBaseY + 6.0f;
|
||||||
|
if (imgY < minY) imgY = minY;
|
||||||
|
SDL_FRect dst{ imgX, imgY, targetW, targetH };
|
||||||
|
SDL_SetTextureBlendMode(coopInfoTexture, SDL_BLENDMODE_BLEND);
|
||||||
|
// Make the coop info image slightly transparent scaled by transition
|
||||||
|
SDL_SetTextureAlphaMod(coopInfoTexture, static_cast<Uint8>(std::round(200.0f * alphaFactor)));
|
||||||
|
SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst);
|
||||||
|
hasCoopImg = true;
|
||||||
|
|
||||||
|
// Only draw the instructional overlay text when choosing partner.
|
||||||
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
|
FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont;
|
||||||
|
if (f) {
|
||||||
|
const float pad = 38.0f;
|
||||||
|
float textX = panelBaseX + pad;
|
||||||
|
// Position the text over the lower portion of the image (overlay)
|
||||||
|
// 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)
|
||||||
|
const std::vector<std::string> bullets = {
|
||||||
|
"The playfield is shared between two players",
|
||||||
|
"Each player controls one half of the grid",
|
||||||
|
"A line clears only when both halves are filled",
|
||||||
|
"Timing and coordination are essential"
|
||||||
|
};
|
||||||
|
float bulletScale = 0.78f;
|
||||||
|
SDL_Color bulletCol{200,220,230,220};
|
||||||
|
bulletCol.a = static_cast<Uint8>(std::round(bulletCol.a * alphaFactor));
|
||||||
|
int sampleLW = 0, sampleLH = 0;
|
||||||
|
f->measure(bullets[0], bulletScale, sampleLW, sampleLH);
|
||||||
|
|
||||||
|
// Header: move it up by one sample row so it sits higher
|
||||||
|
const std::string header = "* HOW TO PLAY – COOPERATE MODE *";
|
||||||
|
float headerScale = 0.95f;
|
||||||
|
int hW=0, hH=0; f->measure(header, headerScale, hW, hH);
|
||||||
|
float hx = panelBaseX + (panelW - static_cast<float>(hW)) * 0.5f + 40.0f; // nudge header right by 40px
|
||||||
|
float headerY = textY - static_cast<float>(sampleLH);
|
||||||
|
SDL_Color headerCol = SDL_Color{220,240,255,230}; headerCol.a = static_cast<Uint8>(std::round(headerCol.a * alphaFactor));
|
||||||
|
f->draw(renderer, hx, headerY, header, headerScale, headerCol);
|
||||||
|
// Start body text slightly below header
|
||||||
|
textY = headerY + static_cast<float>(hH) + 8.0f;
|
||||||
|
|
||||||
|
// Shift non-header text to the right by 100px and down by 20px
|
||||||
|
float bulletX = textX + 200.0f;
|
||||||
|
textY += 20.0f;
|
||||||
|
for (const auto &line : bullets) {
|
||||||
|
std::string withBullet = std::string("• ") + line;
|
||||||
|
f->draw(renderer, bulletX, textY, withBullet, bulletScale, bulletCol);
|
||||||
|
int lw=0, lH=0; f->measure(withBullet, bulletScale, lw, lH);
|
||||||
|
textY += static_cast<float>(lH) + 6.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOAL section (aligned with shifted bullets)
|
||||||
|
textY += 6.0f;
|
||||||
|
std::string goalTitle = "GOAL:";
|
||||||
|
SDL_Color goalTitleCol = SDL_Color{255,215,80,230}; goalTitleCol.a = static_cast<Uint8>(std::round(goalTitleCol.a * alphaFactor));
|
||||||
|
f->draw(renderer, bulletX, textY, goalTitle, 0.88f, goalTitleCol);
|
||||||
|
int gW=0, gH=0; f->measure(goalTitle, 0.88f, gW, gH);
|
||||||
|
float goalX = bulletX + static_cast<float>(gW) + 10.0f;
|
||||||
|
std::string goalText = "Clear lines together and achieve the highest TEAM SCORE";
|
||||||
|
SDL_Color goalTextCol = SDL_Color{220,240,255,220}; goalTextCol.a = static_cast<Uint8>(std::round(goalTextCol.a * alphaFactor));
|
||||||
|
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.
|
||||||
|
const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading
|
||||||
|
float rawBtn = (alphaFactor - btnDelay) / (1.0f - btnDelay);
|
||||||
|
rawBtn = std::clamp(rawBtn, 0.0f, 1.0f);
|
||||||
|
// ease-in (squared) for a slower, smoother fade
|
||||||
|
float buttonFade = rawBtn * rawBtn;
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Step 1: choose partner mode
|
||||||
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
|
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
|
||||||
|
// highscores-area choice buttons are shown when coop setup is active.
|
||||||
|
|
||||||
// Inline exit HUD (no opaque background) - slides into the highscores area
|
// Inline exit HUD (no opaque background) - slides into the highscores area
|
||||||
if (exitTransition > 0.0) {
|
if (exitTransition > 0.0) {
|
||||||
float easedE = static_cast<float>(exitTransition);
|
float easedE = static_cast<float>(exitTransition);
|
||||||
@ -1465,4 +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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
@ -20,6 +26,10 @@ public:
|
|||||||
// Show or hide the inline ABOUT panel (menu-style)
|
// Show or hide the inline ABOUT panel (menu-style)
|
||||||
void showAboutPanel(bool show);
|
void showAboutPanel(bool show);
|
||||||
|
|
||||||
|
// Show or hide the inline COOPERATE setup panel (2P vs AI).
|
||||||
|
// If `resumeMusic` is false when hiding, the menu music will not be restarted.
|
||||||
|
void showCoopSetupPanel(bool show, bool resumeMusic = true);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT
|
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT
|
||||||
|
|
||||||
@ -94,4 +104,37 @@ private:
|
|||||||
double aboutTransition = 0.0; // 0..1
|
double aboutTransition = 0.0; // 0..1
|
||||||
double aboutTransitionDurationMs = 360.0;
|
double aboutTransitionDurationMs = 360.0;
|
||||||
int aboutDirection = 1; // 1 show, -1 hide
|
int aboutDirection = 1; // 1 show, -1 hide
|
||||||
|
|
||||||
|
// Coop setup panel (inline HUD like Exit/Help)
|
||||||
|
bool coopSetupVisible = false;
|
||||||
|
bool coopSetupAnimating = false;
|
||||||
|
double coopSetupTransition = 0.0; // 0..1
|
||||||
|
double coopSetupTransitionDurationMs = 320.0;
|
||||||
|
int coopSetupDirection = 1; // 1 show, -1 hide
|
||||||
|
// 0 = Local co-op (2 players), 1 = AI partner, 2 = 2 player (network)
|
||||||
|
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;
|
||||||
|
// Optional cooperative info image shown when coop setup panel is active
|
||||||
|
SDL_Texture* coopInfoTexture = nullptr;
|
||||||
|
int coopInfoTexW = 0;
|
||||||
|
int coopInfoTexH = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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,10 +26,18 @@ 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);
|
||||||
|
const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession;
|
||||||
|
|
||||||
|
// 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);
|
ctx.game->reset(*ctx.startLevelSelection);
|
||||||
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
||||||
ctx.coopGame->reset(*ctx.startLevelSelection);
|
ctx.coopGame->reset(*ctx.startLevelSelection);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
ctx.game->setPaused(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
||||||
@ -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,7 +175,57 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (coopActive && ctx.coopGame) {
|
if (coopActive && ctx.coopGame) {
|
||||||
// Player 1 (left): A/D move via DAS in ApplicationManager; here handle rotations/hold/hard-drop
|
// 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);
|
||||||
|
|
||||||
|
// Player 1 (left): when AI is enabled it controls the left side so
|
||||||
|
// ignore direct player input for the left board.
|
||||||
|
if (coopAIEnabled) {
|
||||||
|
// Left side controlled by AI; skip left-side input handling here.
|
||||||
|
} else {
|
||||||
|
// Player 1 manual controls (left side)
|
||||||
if (e.key.scancode == SDL_SCANCODE_W) {
|
if (e.key.scancode == SDL_SCANCODE_W) {
|
||||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
|
||||||
return;
|
return;
|
||||||
@ -168,8 +244,7 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
|
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Player 2 (right): arrow keys move via DAS; rotations/hold/hard-drop here
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
|
||||||
@ -183,6 +258,10 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
|
if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
|
||||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
|
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
|
||||||
|
if (coopAIEnabled) {
|
||||||
|
// Mirror human-initiated hard-drop to AI on left
|
||||||
|
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
|
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
|
||||||
@ -303,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);
|
||||||
@ -411,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(
|
||||||
@ -456,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,
|
||||||
|
|||||||
@ -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;
|
||||||
@ -79,12 +82,33 @@ struct StateContext {
|
|||||||
int* challengeStoryLevel = nullptr; // Cached level for the current story line
|
int* challengeStoryLevel = nullptr; // Cached level for the current story line
|
||||||
float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade
|
float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade
|
||||||
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.
|
||||||
|
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
390
src/states/VideoState.cpp
Normal 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
67
src/states/VideoState.h
Normal 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;
|
||||||
|
};
|
||||||
@ -13,7 +13,7 @@ static bool pointInRect(const SDL_FRect& r, float x, float y) {
|
|||||||
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
|
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI) {
|
||||||
BottomMenu menu{};
|
BottomMenu menu{};
|
||||||
|
|
||||||
auto rects = computeMenuButtonRects(params);
|
auto rects = computeMenuButtonRects(params);
|
||||||
@ -22,6 +22,7 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
|||||||
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
||||||
|
|
||||||
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
||||||
|
// Always show a neutral "COOPERATE" label (remove per-mode suffixes)
|
||||||
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
|
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
|
||||||
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
|
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
|
||||||
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
|
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
|
||||||
|
|||||||
@ -35,7 +35,7 @@ struct BottomMenu {
|
|||||||
std::array<Button, MENU_BTN_COUNT> buttons{};
|
std::array<Button, MENU_BTN_COUNT> buttons{};
|
||||||
};
|
};
|
||||||
|
|
||||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel);
|
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI);
|
||||||
|
|
||||||
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
|
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
|
||||||
// hoveredIndex: -1..7
|
// hoveredIndex: -1..7
|
||||||
|
|||||||
172
src/video/VideoPlayer.cpp
Normal file
172
src/video/VideoPlayer.cpp
Normal 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
59
src/video/VideoPlayer.h
Normal 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
38
tests/test_board.cpp
Normal 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();
|
||||||
|
}
|
||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user