Compare commits
47 Commits
5b9eb5f0e3
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 516aa16737 | |||
| 735e966608 | |||
| 68b35ea57b | |||
| 938988c876 | |||
| 03bdc82dc1 | |||
| 17cb64c9d4 | |||
| 6ef93e4c9c | |||
| e2dd768faf | |||
| 0b546ce25c | |||
| 45086e58d8 | |||
| b1f2033880 | |||
| 5fd3febd8e | |||
| 60d6a9e740 | |||
| e1921858ed | |||
| 14cb96345c | |||
| d28feb3276 | |||
| a7a3ae9055 | |||
| 5ec4bf926b | |||
| 0e04617968 | |||
| b450e2af21 | |||
| a65756f298 | |||
| dac312ef2b | |||
| 953d6af701 | |||
| c14e305a4a | |||
| fb036dede5 | |||
| 3c9dc0ff65 | |||
| d3ca238a51 | |||
| a729dc089e | |||
| 18463774e9 | |||
| 694243ac89 | |||
| 60ddc9ddd3 | |||
| 70946fc720 | |||
| fb82ac06d0 | |||
| 494f906435 | |||
| 50c869536d | |||
| 0b99911f5d | |||
| 33d5eedec8 | |||
| 744268fedd | |||
| 06aa63f548 | |||
| a9943ce8bf | |||
| b46af7ab1d | |||
| ab22d4c34f | |||
| e2d6ea64a4 | |||
| 322744c296 | |||
| cf3e897752 | |||
| 4efb60bb5b | |||
| afd7fdf18d |
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
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
settings.ini
|
||||
|
||||
# vcpkg
|
||||
/vcpkg_installed/
|
||||
@ -70,7 +71,4 @@ dist_package/
|
||||
# Local environment files (if any)
|
||||
.env
|
||||
|
||||
# Ignore local settings file
|
||||
settings.ini
|
||||
|
||||
# End of .gitignore
|
||||
|
||||
@ -28,11 +28,14 @@ find_package(SDL3_ttf CONFIG REQUIRED)
|
||||
find_package(SDL3_image CONFIG REQUIRED)
|
||||
find_package(cpr CONFIG REQUIRED)
|
||||
find_package(nlohmann_json CONFIG REQUIRED)
|
||||
find_package(unofficial-enet CONFIG REQUIRED)
|
||||
|
||||
set(TETRIS_SOURCES
|
||||
src/main.cpp
|
||||
src/app/TetrisApp.cpp
|
||||
src/gameplay/core/Game.cpp
|
||||
src/gameplay/coop/CoopGame.cpp
|
||||
src/gameplay/coop/CoopAIController.cpp
|
||||
src/core/GravityManager.cpp
|
||||
src/core/state/StateManager.cpp
|
||||
# New core architecture classes
|
||||
@ -43,25 +46,33 @@ set(TETRIS_SOURCES
|
||||
src/core/Settings.cpp
|
||||
src/graphics/renderers/RenderManager.cpp
|
||||
src/persistence/Scores.cpp
|
||||
src/network/supabase_client.cpp
|
||||
src/network/NetSession.cpp
|
||||
src/graphics/effects/Starfield.cpp
|
||||
src/graphics/effects/Starfield3D.cpp
|
||||
src/graphics/effects/SpaceWarp.cpp
|
||||
src/graphics/ui/Font.cpp
|
||||
src/graphics/ui/HelpOverlay.cpp
|
||||
src/graphics/renderers/GameRenderer.cpp
|
||||
src/graphics/renderers/SyncLineRenderer.cpp
|
||||
src/graphics/renderers/UIRenderer.cpp
|
||||
src/audio/Audio.cpp
|
||||
src/audio/AudioManager.cpp
|
||||
src/renderer/SDLRenderer.cpp
|
||||
src/gameplay/effects/LineEffect.cpp
|
||||
src/audio/SoundEffect.cpp
|
||||
src/video/VideoPlayer.cpp
|
||||
src/ui/MenuLayout.cpp
|
||||
src/ui/BottomMenu.cpp
|
||||
src/app/BackgroundManager.cpp
|
||||
src/app/Fireworks.cpp
|
||||
src/app/AssetLoader.cpp
|
||||
src/app/TextureLoader.cpp
|
||||
src/resources/ResourceManager.cpp
|
||||
src/states/LoadingManager.cpp
|
||||
# State implementations (new)
|
||||
src/states/LoadingState.cpp
|
||||
src/states/VideoState.cpp
|
||||
src/states/MenuState.cpp
|
||||
src/states/OptionsState.cpp
|
||||
src/states/LevelSelectorState.cpp
|
||||
@ -156,10 +167,17 @@ if(APPLE)
|
||||
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)
|
||||
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid)
|
||||
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
|
||||
endif()
|
||||
if(APPLE)
|
||||
# Needed for MP3 decoding via AudioToolbox on macOS
|
||||
@ -186,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")
|
||||
endif()
|
||||
|
||||
# GoogleTest-based board unit tests
|
||||
find_package(GTest CONFIG REQUIRED)
|
||||
add_executable(test_board
|
||||
tests/test_board.cpp
|
||||
src/logic/Board.cpp
|
||||
)
|
||||
target_include_directories(test_board PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||
target_link_libraries(test_board PRIVATE GTest::gtest_main)
|
||||
add_test(NAME BoardTests COMMAND test_board)
|
||||
|
||||
if(EXISTS "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
||||
target_include_directories(test_board PRIVATE "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
||||
endif()
|
||||
|
||||
# Add new src subfolders to include path so old #includes continue to work
|
||||
target_include_directories(spacetris PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/src/audio
|
||||
${CMAKE_SOURCE_DIR}/src/video
|
||||
${CMAKE_SOURCE_DIR}/src/gameplay
|
||||
${CMAKE_SOURCE_DIR}/src/graphics
|
||||
${CMAKE_SOURCE_DIR}/src/persistence
|
||||
|
||||
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
@ -1,6 +1,10 @@
|
||||
#include "app/AssetLoader.h"
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <algorithm>
|
||||
#include "app/TextureLoader.h"
|
||||
|
||||
#include "utils/ImagePathResolver.h"
|
||||
#include <filesystem>
|
||||
|
||||
AssetLoader::AssetLoader() = default;
|
||||
|
||||
@ -37,6 +41,10 @@ void AssetLoader::shutdown() {
|
||||
m_renderer = nullptr;
|
||||
}
|
||||
|
||||
void AssetLoader::setResourceManager(resources::ResourceManager* mgr) {
|
||||
m_resourceManager = mgr;
|
||||
}
|
||||
|
||||
void AssetLoader::setBasePath(const std::string& basePath) {
|
||||
m_basePath = basePath;
|
||||
}
|
||||
@ -65,24 +73,25 @@ bool AssetLoader::performStep() {
|
||||
|
||||
std::string fullPath = m_basePath.empty() ? path : (m_basePath + "/" + path);
|
||||
|
||||
SDL_Surface* surf = IMG_Load(fullPath.c_str());
|
||||
if (!surf) {
|
||||
std::lock_guard<std::mutex> lk(m_errorsMutex);
|
||||
m_errors.push_back(std::string("IMG_Load failed: ") + fullPath + " -> " + SDL_GetError());
|
||||
// Diagnostic: resolve path and check file existence
|
||||
const std::string resolved = AssetPath::resolveImagePath(path);
|
||||
bool exists = false;
|
||||
try { if (!resolved.empty()) exists = std::filesystem::exists(std::filesystem::u8path(resolved)); } catch (...) { exists = false; }
|
||||
|
||||
// Use TextureLoader to centralize loading and ResourceManager caching
|
||||
TextureLoader loader(m_loadedTasks, m_currentLoading, m_currentLoadingMutex, m_errors, m_errorsMutex);
|
||||
loader.setResourceManager(m_resourceManager);
|
||||
// Pass the original queued path (not the full resolved path) so caching keys stay consistent
|
||||
SDL_Texture* tex = loader.loadFromImage(m_renderer, path);
|
||||
if (!tex) {
|
||||
// errors have been recorded by TextureLoader
|
||||
} else {
|
||||
SDL_Texture* tex = SDL_CreateTextureFromSurface(m_renderer, surf);
|
||||
SDL_DestroySurface(surf);
|
||||
if (!tex) {
|
||||
std::lock_guard<std::mutex> lk(m_errorsMutex);
|
||||
m_errors.push_back(std::string("CreateTexture failed: ") + fullPath);
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
auto& slot = m_textures[path];
|
||||
if (slot && slot != tex) {
|
||||
SDL_DestroyTexture(slot);
|
||||
}
|
||||
slot = tex;
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
auto& slot = m_textures[path];
|
||||
if (slot && slot != tex) {
|
||||
SDL_DestroyTexture(slot);
|
||||
}
|
||||
slot = tex;
|
||||
}
|
||||
|
||||
m_loadedTasks.fetch_add(1, std::memory_order_relaxed);
|
||||
@ -104,12 +113,17 @@ void AssetLoader::adoptTexture(const std::string& path, SDL_Texture* texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// register in local map and resource manager
|
||||
std::lock_guard<std::mutex> lk(m_texturesMutex);
|
||||
auto& slot = m_textures[path];
|
||||
if (slot && slot != texture) {
|
||||
SDL_DestroyTexture(slot);
|
||||
}
|
||||
slot = texture;
|
||||
if (m_resourceManager) {
|
||||
std::shared_ptr<void> sp(texture, [](void* t){ SDL_DestroyTexture(static_cast<SDL_Texture*>(t)); });
|
||||
m_resourceManager->put(path, sp);
|
||||
}
|
||||
}
|
||||
|
||||
float AssetLoader::getProgress() const {
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <unordered_map>
|
||||
#include "../resources/ResourceManager.h"
|
||||
|
||||
// Lightweight AssetLoader scaffold.
|
||||
// Responsibilities:
|
||||
@ -22,6 +23,7 @@ public:
|
||||
void shutdown();
|
||||
|
||||
void setBasePath(const std::string& basePath);
|
||||
void setResourceManager(resources::ResourceManager* mgr);
|
||||
|
||||
// Queue a texture path (relative to base path) for loading.
|
||||
void queueTexture(const std::string& path);
|
||||
@ -49,6 +51,7 @@ public:
|
||||
private:
|
||||
SDL_Renderer* m_renderer = nullptr;
|
||||
std::string m_basePath;
|
||||
resources::ResourceManager* m_resourceManager = nullptr;
|
||||
|
||||
// queued paths (simple FIFO)
|
||||
std::vector<std::string> m_queue;
|
||||
|
||||
@ -144,4 +144,7 @@ void draw(SDL_Renderer* renderer, SDL_Texture*) {
|
||||
|
||||
double getLogoAnimCounter() { return logoAnimCounter; }
|
||||
int getHoveredButton() { return hoveredButton; }
|
||||
void spawn(float x, float y) {
|
||||
fireworks.emplace_back(x, y);
|
||||
}
|
||||
} // namespace AppFireworks
|
||||
|
||||
@ -6,4 +6,5 @@ namespace AppFireworks {
|
||||
void update(double frameMs);
|
||||
double getLogoAnimCounter();
|
||||
int getHoveredButton();
|
||||
void spawn(float x, float y);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,8 @@
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "utils/ImagePathResolver.h"
|
||||
|
||||
TextureLoader::TextureLoader(
|
||||
@ -45,6 +47,18 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
|
||||
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
||||
setCurrentLoadingFile(resolvedPath.empty() ? path : resolvedPath);
|
||||
|
||||
// Check filesystem existence for diagnostics (no console log)
|
||||
bool fileExists = false;
|
||||
try { if (!resolvedPath.empty()) fileExists = std::filesystem::exists(std::filesystem::u8path(resolvedPath)); } catch (...) { fileExists = false; }
|
||||
// If resource manager provided, check cache first using the original asset key (path)
|
||||
if (resourceManager_) {
|
||||
if (auto sp = resourceManager_->get<SDL_Texture>(path)) {
|
||||
clearCurrentLoadingFile();
|
||||
loadedTasks_.fetch_add(1);
|
||||
return sp.get();
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
||||
if (!surface) {
|
||||
{
|
||||
@ -54,7 +68,7 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
|
||||
}
|
||||
loadedTasks_.fetch_add(1);
|
||||
clearCurrentLoadingFile();
|
||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to load image %s (resolved: %s): %s", path.c_str(), resolvedPath.c_str(), SDL_GetError());
|
||||
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;
|
||||
}
|
||||
|
||||
@ -66,6 +80,7 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
|
||||
}
|
||||
|
||||
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
|
||||
// surface size preserved in outW/outH; no console log
|
||||
SDL_DestroySurface(surface);
|
||||
|
||||
if (!texture) {
|
||||
@ -80,6 +95,15 @@ SDL_Texture* TextureLoader::loadFromImage(SDL_Renderer* renderer, const std::str
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// No texture-size console diagnostics here
|
||||
|
||||
// cache in resource manager if present
|
||||
if (resourceManager_) {
|
||||
std::shared_ptr<void> sp(texture, [](void* t){ SDL_DestroyTexture(static_cast<SDL_Texture*>(t)); });
|
||||
// store under original asset key (path) so callers using logical asset names find them
|
||||
resourceManager_->put(path, sp);
|
||||
}
|
||||
|
||||
loadedTasks_.fetch_add(1);
|
||||
clearCurrentLoadingFile();
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../resources/ResourceManager.h"
|
||||
|
||||
class TextureLoader {
|
||||
public:
|
||||
@ -16,6 +17,8 @@ public:
|
||||
std::vector<std::string>& assetLoadErrors,
|
||||
std::mutex& assetLoadErrorsMutex);
|
||||
|
||||
void setResourceManager(resources::ResourceManager* mgr) { resourceManager_ = mgr; }
|
||||
|
||||
SDL_Texture* loadFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr);
|
||||
|
||||
private:
|
||||
@ -28,4 +31,6 @@ private:
|
||||
void setCurrentLoadingFile(const std::string& filename);
|
||||
void clearCurrentLoadingFile();
|
||||
void recordAssetLoadError(const std::string& message);
|
||||
|
||||
resources::ResourceManager* resourceManager_ = nullptr;
|
||||
};
|
||||
|
||||
@ -118,6 +118,7 @@ static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int
|
||||
outCh = static_cast<int>(clientFormat.mChannelsPerFrame);
|
||||
return !outPCM.empty();
|
||||
}
|
||||
|
||||
#else
|
||||
static bool decodeMP3(const std::string& path, std::vector<int16_t>& outPCM, int& outRate, int& outCh){
|
||||
(void)outPCM; (void)outRate; (void)outCh; (void)path;
|
||||
@ -184,6 +185,8 @@ void Audio::skipToNextTrack(){
|
||||
void Audio::toggleMute(){ muted=!muted; }
|
||||
void Audio::setMuted(bool m){ muted=m; }
|
||||
|
||||
bool Audio::isMuted() const { return muted; }
|
||||
|
||||
void Audio::nextTrack(){
|
||||
if(tracks.empty()) { current = -1; return; }
|
||||
// Try every track once to find a decodable one
|
||||
|
||||
@ -32,29 +32,27 @@ public:
|
||||
void setSoundVolume(float volume) override;
|
||||
bool isMusicPlaying() const override;
|
||||
|
||||
// Existing Audio class methods
|
||||
bool init(); // initialize backend (MF on Windows)
|
||||
void addTrack(const std::string& path); // decode MP3 -> PCM16 stereo 44100
|
||||
void addTrackAsync(const std::string& path); // add track for background loading
|
||||
void startBackgroundLoading(); // start background thread for loading
|
||||
void waitForLoadingComplete(); // wait for all tracks to finish loading
|
||||
bool isLoadingComplete() const; // check if background loading is done
|
||||
int getLoadedTrackCount() const; // get number of tracks loaded so far
|
||||
void shuffle(); // randomize order
|
||||
void start(); // begin playback
|
||||
void skipToNextTrack(); // advance to the next music track
|
||||
void toggleMute();
|
||||
// Additional IAudioSystem methods (forwarded to concrete implementation)
|
||||
bool init() override;
|
||||
void shutdown() override;
|
||||
void addTrack(const std::string& path) override;
|
||||
void addTrackAsync(const std::string& path) override;
|
||||
void startBackgroundLoading() override;
|
||||
bool isLoadingComplete() const override;
|
||||
int getLoadedTrackCount() const override;
|
||||
void start() override;
|
||||
void skipToNextTrack() override;
|
||||
void shuffle() override;
|
||||
void toggleMute() override;
|
||||
bool isMuted() const override;
|
||||
void setMuted(bool m);
|
||||
bool isMuted() const { return muted; }
|
||||
|
||||
// Menu music support
|
||||
void setMenuTrack(const std::string& path);
|
||||
void playMenuMusic();
|
||||
void playGameMusic();
|
||||
|
||||
// Queue a sound effect to mix over the music (pcm can be mono/stereo, any rate; will be converted)
|
||||
void playSfx(const std::vector<int16_t>& pcm, int channels, int rate, float volume);
|
||||
void shutdown();
|
||||
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;
|
||||
|
||||
// Existing Audio class helper methods
|
||||
void waitForLoadingComplete(); // wait for all tracks to finish loading
|
||||
private:
|
||||
Audio()=default; ~Audio()=default; Audio(const Audio&)=delete; Audio& operator=(const Audio&)=delete;
|
||||
static void SDLCALL streamCallback(void* userdata, SDL_AudioStream* stream, int additional, int total);
|
||||
|
||||
15
src/audio/AudioManager.cpp
Normal file
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 <SDL3/SDL.h>
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/AudioManager.h"
|
||||
#include <cstdio>
|
||||
#include <algorithm>
|
||||
#include <random>
|
||||
@ -93,7 +94,9 @@ void SimpleAudioPlayer::playSound(const std::vector<int16_t>& pcmData, int chann
|
||||
return;
|
||||
}
|
||||
// Route through shared Audio mixer so SFX always play over music
|
||||
Audio::instance().playSfx(pcmData, channels, sampleRate, volume);
|
||||
if (auto sys = AudioManager::get()) {
|
||||
sys->playSfx(pcmData, channels, sampleRate, volume);
|
||||
}
|
||||
}
|
||||
|
||||
bool SoundEffect::loadWAV(const std::string& filePath) {
|
||||
|
||||
@ -21,7 +21,11 @@ std::string Settings::getSettingsPath() {
|
||||
bool Settings::load() {
|
||||
std::ifstream file(getSettingsPath());
|
||||
if (!file.is_open()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults");
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults. Creating settings file with defaults.");
|
||||
// Persist defaults so next run has an explicit settings.ini
|
||||
try {
|
||||
save();
|
||||
} catch (...) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,8 @@ private:
|
||||
Settings& operator=(const Settings&) = delete;
|
||||
|
||||
// Settings values
|
||||
bool m_fullscreen = false;
|
||||
// Default to fullscreen on first run when no settings.ini exists
|
||||
bool m_fullscreen = true;
|
||||
bool m_musicEnabled = true;
|
||||
bool m_soundEnabled = true;
|
||||
bool m_debugEnabled = false;
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include "../interfaces/IInputHandler.h"
|
||||
#include <filesystem>
|
||||
#include "../../audio/Audio.h"
|
||||
#include "../../audio/AudioManager.h"
|
||||
#include "../../audio/SoundEffect.h"
|
||||
#include "../../persistence/Scores.h"
|
||||
#include "../../states/State.h"
|
||||
@ -25,15 +26,26 @@
|
||||
#include "../../graphics/effects/Starfield.h"
|
||||
#include "../../graphics/renderers/GameRenderer.h"
|
||||
#include "../../gameplay/core/Game.h"
|
||||
#include "../../gameplay/coop/CoopGame.h"
|
||||
#include "../../gameplay/effects/LineEffect.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include "../../utils/ImagePathResolver.h"
|
||||
#include <iostream>
|
||||
#include "../../video/VideoPlayer.h"
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
// (Intro video playback is now handled in-process via VideoPlayer)
|
||||
|
||||
ApplicationManager::ApplicationManager() = default;
|
||||
|
||||
@ -54,7 +66,15 @@ void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& r
|
||||
if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
|
||||
app->m_starfield3D->draw(renderer.getSDLRenderer());
|
||||
}
|
||||
|
||||
// If intro video is playing, render it instead of the loading UI
|
||||
if (app->m_introStarted && app->m_videoPlayer) {
|
||||
SDL_Renderer* sdlR = renderer.getSDLRenderer();
|
||||
int winW=0, winH=0; renderer.getWindowSize(winW, winH);
|
||||
app->m_videoPlayer->render(sdlR, winW, winH);
|
||||
SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr);
|
||||
SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f);
|
||||
return;
|
||||
}
|
||||
SDL_Rect logicalVP = {0,0,0,0};
|
||||
float logicalScale = 1.0f;
|
||||
if (app->m_renderManager) {
|
||||
@ -248,7 +268,7 @@ void ApplicationManager::shutdown() {
|
||||
m_running = false;
|
||||
|
||||
// Stop audio systems before tearing down SDL to avoid aborts/asserts
|
||||
Audio::instance().shutdown();
|
||||
if (auto sys = AudioManager::get()) sys->shutdown();
|
||||
SoundEffectManager::instance().shutdown();
|
||||
|
||||
// Cleanup in reverse order of initialization
|
||||
@ -362,11 +382,11 @@ bool ApplicationManager::initializeManagers() {
|
||||
|
||||
// M: Toggle/mute music; start playback if unmuting and not started yet
|
||||
if (!consume && sc == SDL_SCANCODE_M) {
|
||||
Audio::instance().toggleMute();
|
||||
if (auto sys = AudioManager::get()) sys->toggleMute();
|
||||
m_musicEnabled = !m_musicEnabled;
|
||||
if (m_musicEnabled && !m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
|
||||
Audio::instance().shuffle();
|
||||
Audio::instance().start();
|
||||
if (m_musicEnabled && !m_musicStarted && AudioManager::get() && AudioManager::get()->getLoadedTrackCount() > 0) {
|
||||
AudioManager::get()->shuffle();
|
||||
AudioManager::get()->start();
|
||||
m_musicStarted = true;
|
||||
}
|
||||
consume = true;
|
||||
@ -374,11 +394,7 @@ bool ApplicationManager::initializeManagers() {
|
||||
|
||||
// N: Skip to next song in the playlist (or restart menu track)
|
||||
if (!consume && sc == SDL_SCANCODE_N) {
|
||||
Audio::instance().skipToNextTrack();
|
||||
if (!m_musicStarted && Audio::instance().getLoadedTrackCount() > 0) {
|
||||
m_musicStarted = true;
|
||||
m_musicEnabled = true;
|
||||
}
|
||||
if (auto sys = AudioManager::get()) { sys->skipToNextTrack(); if (!m_musicStarted && sys->getLoadedTrackCount() > 0) { m_musicStarted = true; m_musicEnabled = true; } }
|
||||
consume = true;
|
||||
}
|
||||
|
||||
@ -496,13 +512,13 @@ void ApplicationManager::registerServices() {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IInputHandler service");
|
||||
}
|
||||
|
||||
// Register Audio system singleton
|
||||
auto& audioInstance = Audio::instance();
|
||||
auto audioPtr = std::shared_ptr<Audio>(&audioInstance, [](Audio*) {
|
||||
// Custom deleter that does nothing since Audio is a singleton
|
||||
});
|
||||
m_serviceContainer.registerSingleton<IAudioSystem>(audioPtr);
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IAudioSystem service");
|
||||
// Register Audio system singleton (via AudioManager)
|
||||
IAudioSystem* audioInstance = AudioManager::get();
|
||||
if (audioInstance) {
|
||||
std::shared_ptr<IAudioSystem> audioPtr(audioInstance, [](IAudioSystem*){});
|
||||
m_serviceContainer.registerSingleton<IAudioSystem>(audioPtr);
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Registered IAudioSystem service");
|
||||
}
|
||||
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Service registration completed successfully");
|
||||
}
|
||||
@ -561,6 +577,7 @@ bool ApplicationManager::initializeGame() {
|
||||
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
||||
}
|
||||
m_game = std::make_unique<Game>(m_startLevelSelection);
|
||||
m_coopGame = std::make_unique<CoopGame>(m_startLevelSelection);
|
||||
// Wire up sound callbacks as main.cpp did
|
||||
if (m_game) {
|
||||
// Apply global gravity speed multiplier from config
|
||||
@ -580,13 +597,25 @@ bool ApplicationManager::initializeGame() {
|
||||
});
|
||||
}
|
||||
|
||||
if (m_coopGame) {
|
||||
// TODO: tune gravity with Config and shared level scaling once coop rules are finalized
|
||||
m_coopGame->reset(m_startLevelSelection);
|
||||
// Wire coop sound callback to reuse same clear-line VO/SFX behavior
|
||||
m_coopGame->setSoundCallback([&](int linesCleared){
|
||||
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||
if (linesCleared == 2) SoundEffectManager::instance().playRandomSound({"nice_combo"}, 1.0f);
|
||||
else if (linesCleared == 3) SoundEffectManager::instance().playRandomSound({"great_move"}, 1.0f);
|
||||
else if (linesCleared == 4) SoundEffectManager::instance().playRandomSound({"amazing"}, 1.0f);
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare a StateContext-like struct by setting up handlers that capture
|
||||
// pointers and flags. State objects in this refactor expect these to be
|
||||
// available via StateManager event/update/render hooks, so we'll store them
|
||||
// as lambdas that reference members here.
|
||||
|
||||
// Start background music loading similar to main.cpp: Audio init + file discovery
|
||||
Audio::instance().init();
|
||||
if (auto sys = AudioManager::get()) sys->init();
|
||||
// Discover available tracks (up to 100) and queue for background loading
|
||||
m_totalTracks = 0;
|
||||
std::vector<std::string> trackPaths;
|
||||
@ -602,15 +631,15 @@ bool ApplicationManager::initializeGame() {
|
||||
}
|
||||
m_totalTracks = static_cast<int>(trackPaths.size());
|
||||
for (const auto& path : trackPaths) {
|
||||
Audio::instance().addTrackAsync(path);
|
||||
if (auto sys = AudioManager::get()) sys->addTrackAsync(path);
|
||||
}
|
||||
if (m_totalTracks > 0) {
|
||||
Audio::instance().startBackgroundLoading();
|
||||
// Kick off playback now; Audio will pick a track once decoded.
|
||||
// Do not mark as started yet; we'll flip the flag once a track is actually loaded.
|
||||
if (m_musicEnabled) {
|
||||
Audio::instance().shuffle();
|
||||
Audio::instance().start();
|
||||
if (auto sys = AudioManager::get()) sys->startBackgroundLoading();
|
||||
// Kick off playback now; Audio will pick a track once decoded.
|
||||
// Do not mark as started yet; we'll flip the flag once a track is actually loaded.
|
||||
if (m_musicEnabled) {
|
||||
if (auto sys = AudioManager::get()) { sys->shuffle(); sys->start(); }
|
||||
m_musicStarted = true;
|
||||
}
|
||||
m_currentTrackLoading = 1; // mark started
|
||||
}
|
||||
@ -621,6 +650,7 @@ bool ApplicationManager::initializeGame() {
|
||||
{
|
||||
m_stateContext.stateManager = m_stateManager.get();
|
||||
m_stateContext.game = m_game.get();
|
||||
m_stateContext.coopGame = m_coopGame.get();
|
||||
m_stateContext.scores = m_scoreManager.get();
|
||||
m_stateContext.starfield = m_starfield.get();
|
||||
m_stateContext.starfield3D = m_starfield3D.get();
|
||||
@ -765,17 +795,44 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_starfield3D->update(deltaTime / 1000.0f);
|
||||
}
|
||||
|
||||
// Check if loading is complete and transition to menu
|
||||
// Check if loading is complete and transition to next stage
|
||||
if (m_assetManager->isLoadingComplete()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu");
|
||||
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow");
|
||||
|
||||
// Update texture pointers now that assets are loaded
|
||||
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
||||
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
||||
|
||||
bool ok = m_stateManager->setState(AppState::Menu);
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0);
|
||||
traceFile("- to Menu returned");
|
||||
|
||||
// If an intro video exists and hasn't been started, attempt to play it in-process
|
||||
std::filesystem::path introPath = m_introPath;
|
||||
if (!m_introStarted && std::filesystem::exists(introPath)) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str());
|
||||
try {
|
||||
if (!m_videoPlayer) m_videoPlayer = std::make_unique<VideoPlayer>();
|
||||
SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr;
|
||||
if (m_videoPlayer->open(introPath.string(), sdlRend)) {
|
||||
m_introStarted = true;
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process");
|
||||
} else {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping");
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what());
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
} else if (m_introStarted) {
|
||||
// Let VideoPlayer decode frames; once finished, transition to playing
|
||||
if (m_videoPlayer) m_videoPlayer->update();
|
||||
if (!m_videoPlayer || m_videoPlayer->isFinished()) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing");
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
} else {
|
||||
// No intro to play; transition directly to Playing
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing");
|
||||
m_stateManager->setState(AppState::Playing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -881,15 +938,15 @@ void ApplicationManager::setupStateHandlers() {
|
||||
// Start music as soon as at least one track has decoded (don’t wait for all)
|
||||
// Start music as soon as at least one track has decoded (don't wait for all)
|
||||
if (m_musicEnabled && !m_musicStarted) {
|
||||
if (Audio::instance().getLoadedTrackCount() > 0) {
|
||||
Audio::instance().shuffle();
|
||||
Audio::instance().start();
|
||||
m_musicStarted = true;
|
||||
if (auto sys = AudioManager::get()) {
|
||||
if (sys->getLoadedTrackCount() > 0) { sys->shuffle(); sys->start(); m_musicStarted = true; }
|
||||
}
|
||||
}
|
||||
// Track completion status for UI
|
||||
if (!m_musicLoaded && Audio::instance().isLoadingComplete()) {
|
||||
m_musicLoaded = true;
|
||||
if (!m_musicLoaded) {
|
||||
if (auto sys = AudioManager::get()) {
|
||||
if (sys->isLoadingComplete()) m_musicLoaded = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -917,8 +974,8 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_showExitConfirmPopup = true;
|
||||
return;
|
||||
}
|
||||
// S: toggle SFX enable state (music handled globally)
|
||||
if (event.key.scancode == SDL_SCANCODE_S) {
|
||||
// K: toggle SFX enable state (music handled globally)
|
||||
if (event.key.scancode == SDL_SCANCODE_K) {
|
||||
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
||||
}
|
||||
}
|
||||
@ -1217,13 +1274,25 @@ void ApplicationManager::setupStateHandlers() {
|
||||
// "GAME OVER" title
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, {255, 80, 60, 255});
|
||||
|
||||
// Game stats
|
||||
// Game stats (single-player or coop combined)
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level());
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 180, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||
if (m_stateContext.game && m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame) {
|
||||
int leftScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Left);
|
||||
int rightScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Right);
|
||||
int total = leftScore + rightScore;
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d + %d = %d LINES %d LEVEL %d",
|
||||
leftScore,
|
||||
rightScore,
|
||||
total,
|
||||
m_stateContext.coopGame->lines(),
|
||||
m_stateContext.coopGame->level());
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
||||
m_stateContext.game ? m_stateContext.game->score() : 0,
|
||||
m_stateContext.game ? m_stateContext.game->lines() : 0,
|
||||
m_stateContext.game ? m_stateContext.game->level() : 0);
|
||||
}
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 220, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||
|
||||
// Instructions
|
||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255});
|
||||
@ -1237,74 +1306,160 @@ void ApplicationManager::setupStateHandlers() {
|
||||
m_stateManager->registerUpdateHandler(AppState::Playing,
|
||||
[this](double frameMs) {
|
||||
if (!m_stateContext.game) return;
|
||||
|
||||
|
||||
const bool coopActive = m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame;
|
||||
|
||||
// Get current keyboard state
|
||||
const bool *ks = SDL_GetKeyboardState(nullptr);
|
||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||
|
||||
// Handle soft drop
|
||||
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
||||
|
||||
// Handle DAS/ARR movement timing (from original main.cpp)
|
||||
int moveDir = 0;
|
||||
if (left && !right)
|
||||
moveDir = -1;
|
||||
else if (right && !left)
|
||||
moveDir = +1;
|
||||
|
||||
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
||||
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs = DAS; // Set initial delay
|
||||
} else {
|
||||
// Key held - handle repeat timing
|
||||
m_moveTimerMs -= frameMs;
|
||||
if (m_moveTimerMs <= 0) {
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs += ARR; // Set repeat rate
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_moveTimerMs = 0; // Reset timer when no movement
|
||||
}
|
||||
|
||||
// Update held state for next frame
|
||||
m_leftHeld = left;
|
||||
m_rightHeld = right;
|
||||
|
||||
// Handle soft drop boost
|
||||
if (down && !m_stateContext.game->isPaused()) {
|
||||
m_stateContext.game->softDropBoost(frameMs);
|
||||
}
|
||||
|
||||
// Delegate to PlayingState for other updates (gravity, line effects)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Update background fade progression (match main.cpp semantics approx)
|
||||
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
||||
const float LEVEL_FADE_DURATION = 1200.0f;
|
||||
if (m_nextLevelBackgroundTex) {
|
||||
m_levelFadeElapsed += (float)frameMs;
|
||||
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
||||
}
|
||||
|
||||
// Check for game over and transition to GameOver state
|
||||
if (m_stateContext.game->isGameOver()) {
|
||||
// Submit score before transitioning
|
||||
if (m_stateContext.scores) {
|
||||
m_stateContext.scores->submit(
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level(),
|
||||
m_stateContext.game->elapsed()
|
||||
);
|
||||
if (coopActive) {
|
||||
// Paused: suppress all continuous input so pieces don't drift while paused.
|
||||
if (m_stateContext.game->isPaused()) {
|
||||
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||
m_p1MoveTimerMs = 0.0;
|
||||
m_p2MoveTimerMs = 0.0;
|
||||
m_p1LeftHeld = false;
|
||||
m_p1RightHeld = false;
|
||||
m_p2LeftHeld = false;
|
||||
m_p2RightHeld = false;
|
||||
return;
|
||||
}
|
||||
|
||||
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||
bool leftHeld,
|
||||
bool rightHeld,
|
||||
double& timer,
|
||||
SDL_Scancode leftKey,
|
||||
SDL_Scancode rightKey,
|
||||
SDL_Scancode downKey) {
|
||||
bool left = ks[leftKey];
|
||||
bool right = ks[rightKey];
|
||||
bool down = ks[downKey];
|
||||
|
||||
// Soft drop flag
|
||||
m_stateContext.coopGame->setSoftDropping(side, down);
|
||||
|
||||
int moveDir = 0;
|
||||
if (left && !right) moveDir = -1;
|
||||
else if (right && !left) moveDir = +1;
|
||||
|
||||
if (moveDir != 0) {
|
||||
if ((moveDir == -1 && !leftHeld) || (moveDir == +1 && !rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.coopGame->move(side, moveDir);
|
||||
timer = DAS;
|
||||
} else {
|
||||
timer -= frameMs;
|
||||
if (timer <= 0) {
|
||||
m_stateContext.coopGame->move(side, moveDir);
|
||||
timer += ARR;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timer = 0.0;
|
||||
}
|
||||
|
||||
// Soft drop boost: coop uses same gravity path; fall acceleration handled inside tickGravity
|
||||
};
|
||||
|
||||
// Left player (WASD): A/D horizontal, S soft drop
|
||||
handleSide(CoopGame::PlayerSide::Left, m_p1LeftHeld, m_p1RightHeld, m_p1MoveTimerMs,
|
||||
SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
||||
// Right player (arrows): Left/Right horizontal, Down soft drop
|
||||
handleSide(CoopGame::PlayerSide::Right, m_p2LeftHeld, m_p2RightHeld, m_p2MoveTimerMs,
|
||||
SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
||||
|
||||
// Update held flags for next frame
|
||||
m_p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||
m_p1RightHeld = ks[SDL_SCANCODE_D];
|
||||
m_p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||
m_p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||
|
||||
// Gravity / effects
|
||||
m_stateContext.coopGame->tickGravity(frameMs);
|
||||
m_stateContext.coopGame->updateVisualEffects(frameMs);
|
||||
|
||||
// Delegate to PlayingState for any ancillary updates (renderer transport bookkeeping)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Game over transition for coop
|
||||
if (m_stateContext.coopGame->isGameOver()) {
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
|
||||
} else {
|
||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||
|
||||
// Handle soft drop
|
||||
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
||||
|
||||
// Handle DAS/ARR movement timing (from original main.cpp)
|
||||
int moveDir = 0;
|
||||
if (left && !right)
|
||||
moveDir = -1;
|
||||
else if (right && !left)
|
||||
moveDir = +1;
|
||||
|
||||
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
||||
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
||||
// First press - immediate movement
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs = DAS; // Set initial delay
|
||||
} else {
|
||||
// Key held - handle repeat timing
|
||||
m_moveTimerMs -= frameMs;
|
||||
if (m_moveTimerMs <= 0) {
|
||||
m_stateContext.game->move(moveDir);
|
||||
m_moveTimerMs += ARR; // Set repeat rate
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_moveTimerMs = 0; // Reset timer when no movement
|
||||
}
|
||||
|
||||
// Update held state for next frame
|
||||
m_leftHeld = left;
|
||||
m_rightHeld = right;
|
||||
|
||||
// Handle soft drop boost
|
||||
if (down && !m_stateContext.game->isPaused()) {
|
||||
m_stateContext.game->softDropBoost(frameMs);
|
||||
}
|
||||
|
||||
// Delegate to PlayingState for other updates (gravity, line effects)
|
||||
if (m_playingState) {
|
||||
m_playingState->update(frameMs);
|
||||
}
|
||||
|
||||
// Update background fade progression (match main.cpp semantics approx)
|
||||
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
||||
const float LEVEL_FADE_DURATION = 1200.0f;
|
||||
if (m_nextLevelBackgroundTex) {
|
||||
m_levelFadeElapsed += (float)frameMs;
|
||||
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
||||
}
|
||||
|
||||
// Check for game over and transition to GameOver state
|
||||
if (m_stateContext.game->isGameOver()) {
|
||||
// Submit score before transitioning
|
||||
if (m_stateContext.scores) {
|
||||
std::string gt = (m_stateContext.game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||
m_stateContext.scores->submit(
|
||||
m_stateContext.game->score(),
|
||||
m_stateContext.game->lines(),
|
||||
m_stateContext.game->level(),
|
||||
m_stateContext.game->elapsed(),
|
||||
std::string("PLAYER"),
|
||||
gt
|
||||
);
|
||||
}
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
m_stateManager->setState(AppState::GameOver);
|
||||
}
|
||||
});
|
||||
// Debug overlay: show current window and logical sizes on the right side of the screen
|
||||
|
||||
@ -17,6 +17,7 @@ class Starfield;
|
||||
class Starfield3D;
|
||||
class FontAtlas;
|
||||
class LineEffect;
|
||||
class CoopGame;
|
||||
|
||||
// Forward declare state classes (top-level, defined under src/states)
|
||||
class LoadingState;
|
||||
@ -109,6 +110,7 @@ private:
|
||||
std::unique_ptr<ScoreManager> m_scoreManager;
|
||||
// Gameplay pieces
|
||||
std::unique_ptr<Game> m_game;
|
||||
std::unique_ptr<CoopGame> m_coopGame;
|
||||
std::unique_ptr<LineEffect> m_lineEffect;
|
||||
|
||||
// DAS/ARR movement timing (from original main.cpp)
|
||||
@ -118,6 +120,14 @@ private:
|
||||
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
||||
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
||||
|
||||
// Coop DAS/ARR per player
|
||||
bool m_p1LeftHeld = false;
|
||||
bool m_p1RightHeld = false;
|
||||
bool m_p2LeftHeld = false;
|
||||
bool m_p2RightHeld = false;
|
||||
double m_p1MoveTimerMs = 0.0;
|
||||
double m_p2MoveTimerMs = 0.0;
|
||||
|
||||
// State context (must be a member to ensure lifetime)
|
||||
StateContext m_stateContext;
|
||||
|
||||
@ -143,6 +153,11 @@ private:
|
||||
float m_logoAnimCounter = 0.0f;
|
||||
bool m_helpOverlayPausedGame = false;
|
||||
|
||||
// Intro video playback (in-process via FFmpeg)
|
||||
bool m_introStarted = false;
|
||||
std::string m_introPath = "assets/videos/spacetris_intro.mp4";
|
||||
std::unique_ptr<class VideoPlayer> m_videoPlayer;
|
||||
|
||||
// Gameplay background (per-level) with fade, mirroring main.cpp behavior
|
||||
SDL_Texture* m_levelBackgroundTex = nullptr;
|
||||
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
#include "AssetManager.h"
|
||||
#include "../../graphics/ui/Font.h"
|
||||
#include "../../audio/Audio.h"
|
||||
#include "../../audio/AudioManager.h"
|
||||
#include "../../audio/SoundEffect.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <filesystem>
|
||||
#include "../../utils/ImagePathResolver.h"
|
||||
#include "../../core/Config.h"
|
||||
#include "../../resources/AssetPaths.h"
|
||||
|
||||
AssetManager::AssetManager()
|
||||
: m_renderer(nullptr)
|
||||
@ -38,7 +41,7 @@ bool AssetManager::initialize(SDL_Renderer* renderer) {
|
||||
m_renderer = renderer;
|
||||
|
||||
// Get references to singleton systems
|
||||
m_audioSystem = &Audio::instance();
|
||||
m_audioSystem = AudioManager::get();
|
||||
m_soundSystem = &SoundEffectManager::instance();
|
||||
|
||||
m_initialized = true;
|
||||
@ -103,7 +106,34 @@ SDL_Texture* AssetManager::loadTexture(const std::string& id, const std::string&
|
||||
|
||||
SDL_Texture* AssetManager::getTexture(const std::string& id) const {
|
||||
auto it = m_textures.find(id);
|
||||
return (it != m_textures.end()) ? it->second : nullptr;
|
||||
if (it != m_textures.end()) return it->second;
|
||||
|
||||
// Lazy fallback: attempt to load well-known short ids from configured asset paths.
|
||||
std::vector<std::string> candidates;
|
||||
if (id == "logo") {
|
||||
candidates.push_back(std::string(Assets::LOGO));
|
||||
candidates.push_back(Config::Assets::LOGO_BMP);
|
||||
} else if (id == "logo_small") {
|
||||
candidates.push_back(Config::Assets::LOGO_SMALL_BMP);
|
||||
candidates.push_back(std::string(Assets::LOGO));
|
||||
} else if (id == "background") {
|
||||
candidates.push_back(std::string(Assets::MAIN_SCREEN));
|
||||
candidates.push_back(Config::Assets::BACKGROUND_BMP);
|
||||
} else if (id == "blocks") {
|
||||
candidates.push_back(std::string(Assets::BLOCKS_SPRITE));
|
||||
candidates.push_back(Config::Assets::BLOCKS_BMP);
|
||||
} else if (id == "asteroids") {
|
||||
candidates.push_back(std::string(Assets::ASTEROID_SPRITE));
|
||||
}
|
||||
|
||||
for (const auto &candidatePath : candidates) {
|
||||
if (candidatePath.empty()) continue;
|
||||
AssetManager* self = const_cast<AssetManager*>(this);
|
||||
SDL_Texture* tex = self->loadTexture(id, candidatePath);
|
||||
if (tex) return tex;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool AssetManager::unloadTexture(const std::string& id) {
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
#include <memory>
|
||||
#include <functional>
|
||||
#include "../interfaces/IAssetLoader.h"
|
||||
#include "../interfaces/IAssetLoader.h"
|
||||
|
||||
// Forward declarations
|
||||
class FontAtlas;
|
||||
class Audio;
|
||||
class SoundEffectManager;
|
||||
class IAudioSystem;
|
||||
|
||||
/**
|
||||
* AssetManager - Centralized resource management following SOLID principles
|
||||
@ -121,7 +121,7 @@ private:
|
||||
|
||||
// System references
|
||||
SDL_Renderer* m_renderer;
|
||||
Audio* m_audioSystem; // Pointer to singleton
|
||||
IAudioSystem* m_audioSystem; // Pointer to audio system (IAudioSystem)
|
||||
SoundEffectManager* m_soundSystem; // Pointer to singleton
|
||||
|
||||
// Configuration
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @brief Abstract interface for audio system operations
|
||||
@ -52,4 +54,28 @@ public:
|
||||
* @return true if music is playing, false otherwise
|
||||
*/
|
||||
virtual bool isMusicPlaying() const = 0;
|
||||
|
||||
// Extended control methods used by the application
|
||||
virtual bool init() = 0;
|
||||
virtual void shutdown() = 0;
|
||||
|
||||
virtual void addTrack(const std::string& path) = 0;
|
||||
virtual void addTrackAsync(const std::string& path) = 0;
|
||||
virtual void startBackgroundLoading() = 0;
|
||||
virtual bool isLoadingComplete() const = 0;
|
||||
virtual int getLoadedTrackCount() const = 0;
|
||||
|
||||
virtual void start() = 0;
|
||||
virtual void skipToNextTrack() = 0;
|
||||
virtual void shuffle() = 0;
|
||||
|
||||
virtual void toggleMute() = 0;
|
||||
virtual bool isMuted() const = 0;
|
||||
|
||||
virtual void setMenuTrack(const std::string& path) = 0;
|
||||
virtual void playMenuMusic() = 0;
|
||||
virtual void playGameMusic() = 0;
|
||||
|
||||
// Low-level SFX path (raw PCM) used by internal SFX mixer
|
||||
virtual void playSfx(const std::vector<int16_t>& pcm, int channels, int rate, float volume) = 0;
|
||||
};
|
||||
|
||||
@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) {
|
||||
}
|
||||
|
||||
bool StateManager::isValidState(AppState state) const {
|
||||
// All enum values are currently valid
|
||||
return static_cast<int>(state) >= static_cast<int>(AppState::Loading) &&
|
||||
static_cast<int>(state) <= static_cast<int>(AppState::GameOver);
|
||||
switch (state) {
|
||||
case AppState::Loading:
|
||||
case AppState::Video:
|
||||
case AppState::Menu:
|
||||
case AppState::Options:
|
||||
case AppState::LevelSelector:
|
||||
case AppState::Playing:
|
||||
case AppState::LevelSelect:
|
||||
case AppState::GameOver:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool StateManager::canTransitionTo(AppState newState) const {
|
||||
@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const {
|
||||
const char* StateManager::getStateName(AppState state) const {
|
||||
switch (state) {
|
||||
case AppState::Loading: return "Loading";
|
||||
case AppState::Video: return "Video";
|
||||
case AppState::Menu: return "Menu";
|
||||
case AppState::Options: return "Options";
|
||||
case AppState::LevelSelector: return "LevelSelector";
|
||||
|
||||
@ -12,6 +12,7 @@ class RenderManager;
|
||||
// Application states used across the app
|
||||
enum class AppState {
|
||||
Loading,
|
||||
Video,
|
||||
Menu,
|
||||
Options,
|
||||
LevelSelector,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/AudioManager.h"
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
@ -266,6 +267,6 @@ void LineEffect::playLineClearSound(int lineCount) {
|
||||
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
|
||||
if (sample && !sample->empty()) {
|
||||
// Mix via shared Audio device so it layers with music
|
||||
Audio::instance().playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
if (auto sys = AudioManager::get()) sys->playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
}
|
||||
}
|
||||
|
||||
317
src/gameplay/coop/CoopAIController.cpp
Normal file
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);
|
||||
};
|
||||
600
src/gameplay/coop/CoopGame.cpp
Normal file
600
src/gameplay/coop/CoopGame.cpp
Normal file
@ -0,0 +1,600 @@
|
||||
#include "CoopGame.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
||||
constexpr double NES_FPS = 60.0988;
|
||||
constexpr double FRAME_MS = 1000.0 / NES_FPS;
|
||||
|
||||
struct LevelGravity { int framesPerCell; double levelMultiplier; };
|
||||
|
||||
LevelGravity LEVEL_TABLE[30] = {
|
||||
{48,1.0}, {43,1.0}, {38,1.0}, {33,1.0}, {28,1.0}, {23,1.0}, {18,1.0}, {13,1.0}, {8,1.0}, {6,1.0},
|
||||
{5,1.0}, {5,1.0}, {5,1.0}, {4,1.0}, {4,1.0}, {4,1.0}, {3,1.0}, {3,1.0}, {3,1.0}, {2,1.0},
|
||||
{2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {1,1.0}
|
||||
};
|
||||
|
||||
inline double gravityMsForLevelInternal(int level, double globalMultiplier) {
|
||||
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
|
||||
const LevelGravity& lg = LEVEL_TABLE[idx];
|
||||
double frames = lg.framesPerCell * lg.levelMultiplier;
|
||||
return frames * FRAME_MS * globalMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0).
|
||||
static const std::array<Shape, PIECE_COUNT> SHAPES = {{
|
||||
Shape{ 0x0F00, 0x2222, 0x00F0, 0x4444 }, // I
|
||||
Shape{ 0x0660, 0x0660, 0x0660, 0x0660 }, // O
|
||||
Shape{ 0x0E40, 0x4C40, 0x4E00, 0x4640 }, // T
|
||||
Shape{ 0x06C0, 0x4620, 0x06C0, 0x4620 }, // S
|
||||
Shape{ 0x0C60, 0x2640, 0x0C60, 0x2640 }, // Z
|
||||
Shape{ 0x08E0, 0x6440, 0x0E20, 0x44C0 }, // J
|
||||
Shape{ 0x02E0, 0x4460, 0x0E80, 0xC440 }, // L
|
||||
}};
|
||||
}
|
||||
|
||||
CoopGame::CoopGame(int startLevel_) {
|
||||
reset(startLevel_);
|
||||
}
|
||||
|
||||
namespace {
|
||||
uint64_t fnv1a64(uint64_t h, const void* data, size_t size) {
|
||||
const uint8_t* p = static_cast<const uint8_t*>(data);
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
h ^= static_cast<uint64_t>(p[i]);
|
||||
h *= 1099511628211ull;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
uint64_t hashPod(uint64_t h, const T& v) {
|
||||
return fnv1a64(h, &v, sizeof(T));
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt) {
|
||||
std::fill(board.begin(), board.end(), Cell{});
|
||||
rowStates.fill(RowHalfState{});
|
||||
completedLines.clear();
|
||||
hardDropCells.clear();
|
||||
hardDropFxId = 0;
|
||||
hardDropShakeTimerMs = 0.0;
|
||||
_score = 0;
|
||||
_lines = 0;
|
||||
_level = startLevel_;
|
||||
startLevel = startLevel_;
|
||||
gravityMs = gravityMsForLevel(_level);
|
||||
gameOver = false;
|
||||
pieceSequence = 0;
|
||||
elapsedMs = 0.0;
|
||||
|
||||
left = PlayerState{};
|
||||
right = PlayerState{ PlayerSide::Right };
|
||||
|
||||
auto initPlayer = [&](PlayerState& ps, uint32_t seed) {
|
||||
ps.canHold = true;
|
||||
ps.hold.type = PIECE_COUNT;
|
||||
ps.softDropping = false;
|
||||
ps.toppedOut = false;
|
||||
ps.fallAcc = 0.0;
|
||||
ps.lockAcc = 0.0;
|
||||
ps.pieceSeq = 0;
|
||||
ps.score = 0;
|
||||
ps.lines = 0;
|
||||
ps.level = startLevel_;
|
||||
ps.tetrisesMade = 0;
|
||||
ps.currentCombo = 0;
|
||||
ps.maxCombo = 0;
|
||||
ps.comboCount = 0;
|
||||
ps.bag.clear();
|
||||
ps.next.type = PIECE_COUNT;
|
||||
ps.rng.seed(seed);
|
||||
refillBag(ps);
|
||||
};
|
||||
|
||||
if (seedOpt.has_value()) {
|
||||
const uint32_t seed = seedOpt.value();
|
||||
initPlayer(left, seed);
|
||||
initPlayer(right, seed ^ 0x9E3779B9u);
|
||||
} else {
|
||||
// Preserve existing behavior: random seed when not in deterministic mode.
|
||||
std::random_device rd;
|
||||
initPlayer(left, static_cast<uint32_t>(rd()));
|
||||
initPlayer(right, static_cast<uint32_t>(rd()));
|
||||
}
|
||||
|
||||
spawn(left);
|
||||
spawn(right);
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::reset(int startLevel_) {
|
||||
resetInternal(startLevel_, std::nullopt);
|
||||
}
|
||||
|
||||
void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) {
|
||||
resetInternal(startLevel_, seed);
|
||||
}
|
||||
|
||||
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
||||
PlayerState& ps = player(side);
|
||||
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
||||
double oldStep = stepFor(ps.softDropping);
|
||||
double newStep = stepFor(on);
|
||||
if (oldStep <= 0.0 || newStep <= 0.0) {
|
||||
ps.softDropping = on;
|
||||
return;
|
||||
}
|
||||
|
||||
double progress = ps.fallAcc / oldStep;
|
||||
progress = std::clamp(progress, 0.0, 1.0);
|
||||
ps.fallAcc = progress * newStep;
|
||||
ps.softDropping = on;
|
||||
}
|
||||
|
||||
uint64_t CoopGame::computeStateHash() const {
|
||||
uint64_t h = 1469598103934665603ull;
|
||||
|
||||
// Board
|
||||
for (const auto& c : board) {
|
||||
const uint8_t occ = c.occupied ? 1u : 0u;
|
||||
const uint8_t owner = (c.owner == PlayerSide::Left) ? 0u : 1u;
|
||||
const uint8_t val = static_cast<uint8_t>(std::clamp(c.value, 0, 255));
|
||||
h = hashPod(h, occ);
|
||||
h = hashPod(h, owner);
|
||||
h = hashPod(h, val);
|
||||
}
|
||||
|
||||
auto hashPiece = [&](const Piece& p) {
|
||||
const uint8_t type = static_cast<uint8_t>(p.type);
|
||||
const int32_t rot = p.rot;
|
||||
const int32_t x = p.x;
|
||||
const int32_t y = p.y;
|
||||
h = hashPod(h, type);
|
||||
h = hashPod(h, rot);
|
||||
h = hashPod(h, x);
|
||||
h = hashPod(h, y);
|
||||
};
|
||||
|
||||
auto hashPlayer = [&](const PlayerState& ps) {
|
||||
const uint8_t side = (ps.side == PlayerSide::Left) ? 0u : 1u;
|
||||
h = hashPod(h, side);
|
||||
hashPiece(ps.cur);
|
||||
hashPiece(ps.next);
|
||||
hashPiece(ps.hold);
|
||||
const uint8_t canHoldB = ps.canHold ? 1u : 0u;
|
||||
const uint8_t toppedOutB = ps.toppedOut ? 1u : 0u;
|
||||
h = hashPod(h, canHoldB);
|
||||
h = hashPod(h, toppedOutB);
|
||||
h = hashPod(h, ps.score);
|
||||
h = hashPod(h, ps.lines);
|
||||
h = hashPod(h, ps.level);
|
||||
h = hashPod(h, ps.tetrisesMade);
|
||||
h = hashPod(h, ps.currentCombo);
|
||||
h = hashPod(h, ps.maxCombo);
|
||||
h = hashPod(h, ps.comboCount);
|
||||
h = hashPod(h, ps.pieceSeq);
|
||||
|
||||
const uint32_t bagSize = static_cast<uint32_t>(ps.bag.size());
|
||||
h = hashPod(h, bagSize);
|
||||
for (auto t : ps.bag) {
|
||||
const uint8_t tt = static_cast<uint8_t>(t);
|
||||
h = hashPod(h, tt);
|
||||
}
|
||||
};
|
||||
|
||||
hashPlayer(left);
|
||||
hashPlayer(right);
|
||||
|
||||
// Session-wide counters/stats
|
||||
h = hashPod(h, _score);
|
||||
h = hashPod(h, _lines);
|
||||
h = hashPod(h, _level);
|
||||
h = hashPod(h, _tetrisesMade);
|
||||
h = hashPod(h, _currentCombo);
|
||||
h = hashPod(h, _maxCombo);
|
||||
h = hashPod(h, _comboCount);
|
||||
h = hashPod(h, startLevel);
|
||||
h = hashPod(h, pieceSequence);
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
void CoopGame::move(PlayerSide side, int dx) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
tryMove(ps, dx, 0);
|
||||
}
|
||||
|
||||
void CoopGame::rotate(PlayerSide side, int dir) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
|
||||
auto minOccupiedY = [&](const Piece& p) -> int {
|
||||
int minY = 999;
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(p, cx, cy)) continue;
|
||||
minY = std::min(minY, p.y + cy);
|
||||
}
|
||||
}
|
||||
return (minY == 999) ? p.y : minY;
|
||||
};
|
||||
|
||||
auto tryApplyWithTopKick = [&](const Piece& candidate) -> bool {
|
||||
// If rotation would place any occupied cell above the visible grid,
|
||||
// kick it down just enough to keep all blocks visible.
|
||||
int minY = minOccupiedY(candidate);
|
||||
int baseDy = (minY < 0) ? -minY : 0;
|
||||
|
||||
// Try minimal adjustment first; allow a couple extra pixels/rows for safety.
|
||||
for (int dy = baseDy; dy <= baseDy + 2; ++dy) {
|
||||
Piece test = candidate;
|
||||
test.y += dy;
|
||||
if (!collides(ps, test)) {
|
||||
ps.cur = test;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Piece rotated = ps.cur;
|
||||
rotated.rot = (rotated.rot + dir + 4) % 4;
|
||||
|
||||
// Simple wall kick: try in place, then left, then right.
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
rotated.x -= 1;
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
rotated.x += 2;
|
||||
if (tryApplyWithTopKick(rotated)) return;
|
||||
}
|
||||
|
||||
void CoopGame::hardDrop(PlayerSide side) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
|
||||
hardDropCells.clear();
|
||||
bool moved = false;
|
||||
int dropped = 0;
|
||||
while (tryMove(ps, 0, 1)) {
|
||||
moved = true;
|
||||
dropped++;
|
||||
// Record path for potential effects
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||
int px = ps.cur.x + cx;
|
||||
int py = ps.cur.y + cy;
|
||||
if (py >= 0) {
|
||||
hardDropCells.push_back(SDL_Point{ px, py });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (moved) {
|
||||
_score += dropped; // 1 point per cell, matches single-player hard drop
|
||||
ps.score += dropped;
|
||||
hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS;
|
||||
hardDropFxId++;
|
||||
}
|
||||
lock(ps);
|
||||
}
|
||||
|
||||
void CoopGame::holdCurrent(PlayerSide side) {
|
||||
PlayerState& ps = player(side);
|
||||
if (gameOver || ps.toppedOut) return;
|
||||
if (!ps.canHold) return;
|
||||
if (ps.hold.type == PIECE_COUNT) {
|
||||
ps.hold = ps.cur;
|
||||
spawn(ps);
|
||||
} else {
|
||||
std::swap(ps.cur, ps.hold);
|
||||
ps.cur.rot = 0;
|
||||
ps.cur.x = columnMin(ps.side) + 3;
|
||||
// Match single-player spawn height (I starts higher)
|
||||
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||
ps.pieceSeq++;
|
||||
pieceSequence++;
|
||||
}
|
||||
ps.canHold = false;
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
|
||||
void CoopGame::tickGravity(double frameMs) {
|
||||
if (gameOver) return;
|
||||
|
||||
elapsedMs += frameMs;
|
||||
|
||||
auto stepPlayer = [&](PlayerState& ps) {
|
||||
if (ps.toppedOut) return;
|
||||
double step = ps.softDropping ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||
ps.fallAcc += frameMs;
|
||||
while (ps.fallAcc >= step) {
|
||||
ps.fallAcc -= step;
|
||||
if (!tryMove(ps, 0, 1)) {
|
||||
ps.lockAcc += step;
|
||||
if (ps.lockAcc >= LOCK_DELAY_MS) {
|
||||
lock(ps);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Award soft drop points when actively holding down
|
||||
if (ps.softDropping) {
|
||||
_score += 1;
|
||||
ps.score += 1;
|
||||
}
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stepPlayer(left);
|
||||
stepPlayer(right);
|
||||
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::updateVisualEffects(double frameMs) {
|
||||
if (hardDropShakeTimerMs > 0.0) {
|
||||
hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs);
|
||||
}
|
||||
}
|
||||
|
||||
double CoopGame::hardDropShakeStrength() const {
|
||||
if (hardDropShakeTimerMs <= 0.0) return 0.0;
|
||||
return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0);
|
||||
}
|
||||
|
||||
double CoopGame::gravityMsForLevel(int level) const {
|
||||
return gravityMsForLevelInternal(level, gravityGlobalMultiplier);
|
||||
}
|
||||
|
||||
bool CoopGame::cellFilled(const Piece& p, int cx, int cy) {
|
||||
if (p.type >= PIECE_COUNT) return false;
|
||||
const Shape& shape = SHAPES[p.type];
|
||||
uint16_t mask = shape[p.rot % 4];
|
||||
int bitIndex = cy * 4 + cx;
|
||||
// Masks are defined row-major 4x4 with bit 0 = (0,0) (same convention as classic).
|
||||
return (mask >> bitIndex) & 1;
|
||||
}
|
||||
|
||||
void CoopGame::clearCompletedLines() {
|
||||
if (completedLines.empty()) return;
|
||||
clearLinesInternal();
|
||||
completedLines.clear();
|
||||
updateRowStates();
|
||||
}
|
||||
|
||||
void CoopGame::refillBag(PlayerState& ps) {
|
||||
ps.bag.clear();
|
||||
ps.bag.reserve(PIECE_COUNT);
|
||||
for (int i = 0; i < PIECE_COUNT; ++i) {
|
||||
ps.bag.push_back(static_cast<PieceType>(i));
|
||||
}
|
||||
std::shuffle(ps.bag.begin(), ps.bag.end(), ps.rng);
|
||||
}
|
||||
|
||||
CoopGame::Piece CoopGame::drawFromBag(PlayerState& ps) {
|
||||
if (ps.bag.empty()) {
|
||||
refillBag(ps);
|
||||
}
|
||||
PieceType t = ps.bag.back();
|
||||
ps.bag.pop_back();
|
||||
Piece p{};
|
||||
p.type = t;
|
||||
return p;
|
||||
}
|
||||
|
||||
void CoopGame::spawn(PlayerState& ps) {
|
||||
if (ps.next.type == PIECE_COUNT) {
|
||||
ps.next = drawFromBag(ps);
|
||||
}
|
||||
ps.cur = ps.next;
|
||||
ps.cur.rot = 0;
|
||||
ps.cur.x = columnMin(ps.side) + 3; // center within side
|
||||
// Match single-player spawn height (I starts higher)
|
||||
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||
ps.next = drawFromBag(ps);
|
||||
ps.canHold = true;
|
||||
ps.softDropping = false;
|
||||
ps.lockAcc = 0.0;
|
||||
ps.fallAcc = 0.0;
|
||||
ps.pieceSeq++;
|
||||
pieceSequence++;
|
||||
if (collides(ps, ps.cur)) {
|
||||
ps.toppedOut = true;
|
||||
// Cooperative mode: game ends when any player tops out.
|
||||
gameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
||||
int minX = columnMin(ps.side);
|
||||
int maxX = columnMax(ps.side);
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(p, cx, cy)) continue;
|
||||
int px = p.x + cx;
|
||||
int py = p.y + cy;
|
||||
if (px < minX || px > maxX) return true;
|
||||
if (py >= ROWS) return true;
|
||||
if (py < 0) continue; // allow spawn above board
|
||||
int idx = py * COLS + px;
|
||||
if (board[idx].occupied) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CoopGame::tryMove(PlayerState& ps, int dx, int dy) {
|
||||
Piece test = ps.cur;
|
||||
test.x += dx;
|
||||
test.y += dy;
|
||||
if (collides(ps, test)) return false;
|
||||
ps.cur = test;
|
||||
if (dy > 0) {
|
||||
ps.lockAcc = 0.0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CoopGame::lock(PlayerState& ps) {
|
||||
// Write piece into the board
|
||||
for (int cy = 0; cy < 4; ++cy) {
|
||||
for (int cx = 0; cx < 4; ++cx) {
|
||||
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||
int px = ps.cur.x + cx;
|
||||
int py = ps.cur.y + cy;
|
||||
if (py < 0 || py >= ROWS) continue;
|
||||
int idx = py * COLS + px;
|
||||
board[idx].occupied = true;
|
||||
board[idx].owner = ps.side;
|
||||
board[idx].value = static_cast<int>(ps.cur.type) + 1;
|
||||
}
|
||||
}
|
||||
// Detect completed lines and apply rewards but DO NOT clear them here.
|
||||
// Clearing is deferred to the visual `LineEffect` system (as in single-player)
|
||||
findCompletedLines();
|
||||
if (!completedLines.empty()) {
|
||||
int cleared = static_cast<int>(completedLines.size());
|
||||
applyLineClearRewards(ps, cleared);
|
||||
// Notify audio layer if present (matches single-player behavior)
|
||||
if (soundCallback) soundCallback(cleared);
|
||||
// Leave `completedLines` populated; `clearCompletedLines()` will be
|
||||
// invoked by the state when the LineEffect finishes.
|
||||
} else {
|
||||
_currentCombo = 0;
|
||||
ps.currentCombo = 0;
|
||||
}
|
||||
spawn(ps);
|
||||
}
|
||||
|
||||
void CoopGame::findCompletedLines() {
|
||||
completedLines.clear();
|
||||
for (int r = 0; r < ROWS; ++r) {
|
||||
bool leftFull = true;
|
||||
bool rightFull = true;
|
||||
for (int c = 0; c < COLS; ++c) {
|
||||
const Cell& cell = board[r * COLS + c];
|
||||
if (!cell.occupied) {
|
||||
if (c < 10) leftFull = false; else rightFull = false;
|
||||
}
|
||||
}
|
||||
rowStates[r].leftFull = leftFull;
|
||||
rowStates[r].rightFull = rightFull;
|
||||
if (leftFull && rightFull) {
|
||||
completedLines.push_back(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) {
|
||||
if (cleared <= 0) return;
|
||||
|
||||
// Base NES scoring scaled by shared level (level 0 => 1x multiplier)
|
||||
int base = 0;
|
||||
switch (cleared) {
|
||||
case 1: base = 40; break;
|
||||
case 2: base = 100; break;
|
||||
case 3: base = 300; break;
|
||||
case 4: base = 1200; break;
|
||||
default: base = 0; break;
|
||||
}
|
||||
_score += base * (_level + 1);
|
||||
creditPlayer.score += base * (creditPlayer.level + 1);
|
||||
|
||||
// Also award a trivial per-line bonus to both players so clears benefit
|
||||
// both participants equally (as requested).
|
||||
if (cleared > 0) {
|
||||
left.score += cleared;
|
||||
right.score += cleared;
|
||||
}
|
||||
|
||||
_lines += cleared;
|
||||
// Credit both players with the cleared lines so cooperative play counts for both
|
||||
left.lines += cleared;
|
||||
right.lines += cleared;
|
||||
|
||||
_currentCombo += 1;
|
||||
if (_currentCombo > _maxCombo) _maxCombo = _currentCombo;
|
||||
if (cleared > 1) {
|
||||
_comboCount += 1;
|
||||
}
|
||||
if (cleared == 4) {
|
||||
_tetrisesMade += 1;
|
||||
}
|
||||
|
||||
creditPlayer.currentCombo += 1;
|
||||
if (creditPlayer.currentCombo > creditPlayer.maxCombo) creditPlayer.maxCombo = creditPlayer.currentCombo;
|
||||
if (cleared > 1) {
|
||||
creditPlayer.comboCount += 1;
|
||||
}
|
||||
if (cleared == 4) {
|
||||
creditPlayer.tetrisesMade += 1;
|
||||
}
|
||||
|
||||
// Level progression mirrors single-player: threshold after (startLevel+1)*10 then every 10 lines
|
||||
int targetLevel = startLevel;
|
||||
int firstThreshold = (startLevel + 1) * 10;
|
||||
if (_lines >= firstThreshold) {
|
||||
targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10;
|
||||
}
|
||||
if (targetLevel > _level) {
|
||||
_level = targetLevel;
|
||||
gravityMs = gravityMsForLevel(_level);
|
||||
if (levelUpCallback) levelUpCallback(_level);
|
||||
}
|
||||
|
||||
// Per-player level progression mirrors the shared rules but is driven by
|
||||
// that player's credited line clears.
|
||||
{
|
||||
int pTargetLevel = startLevel;
|
||||
int pFirstThreshold = (startLevel + 1) * 10;
|
||||
if (creditPlayer.lines >= pFirstThreshold) {
|
||||
pTargetLevel = startLevel + 1 + (creditPlayer.lines - pFirstThreshold) / 10;
|
||||
}
|
||||
creditPlayer.level = std::max(creditPlayer.level, pTargetLevel);
|
||||
}
|
||||
}
|
||||
|
||||
void CoopGame::clearLinesInternal() {
|
||||
if (completedLines.empty()) return;
|
||||
std::sort(completedLines.begin(), completedLines.end());
|
||||
for (int idx = static_cast<int>(completedLines.size()) - 1; idx >= 0; --idx) {
|
||||
int row = completedLines[idx];
|
||||
for (int y = row; y > 0; --y) {
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[y * COLS + x] = board[(y - 1) * COLS + x];
|
||||
}
|
||||
}
|
||||
for (int x = 0; x < COLS; ++x) {
|
||||
board[x] = Cell{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sound callback (optional) - invoked when lines are detected so audio can play
|
||||
// (set via setSoundCallback)
|
||||
// NOTE: defined inline in header as a std::function member; forward usage above
|
||||
|
||||
void CoopGame::updateRowStates() {
|
||||
for (int r = 0; r < ROWS; ++r) {
|
||||
bool leftFull = true;
|
||||
bool rightFull = true;
|
||||
for (int c = 0; c < COLS; ++c) {
|
||||
const Cell& cell = board[r * COLS + c];
|
||||
if (!cell.occupied) {
|
||||
if (c < 10) leftFull = false; else rightFull = false;
|
||||
}
|
||||
}
|
||||
rowStates[r].leftFull = leftFull;
|
||||
rowStates[r].rightFull = rightFull;
|
||||
}
|
||||
}
|
||||
167
src/gameplay/coop/CoopGame.h
Normal file
167
src/gameplay/coop/CoopGame.h
Normal file
@ -0,0 +1,167 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include "../core/Game.h" // For PieceType enums and gravity table helpers
|
||||
|
||||
// Cooperative two-player session with a shared 20-column board split into halves.
|
||||
// This is an early scaffold: rules and rendering hooks will be iterated in follow-up passes.
|
||||
class CoopGame {
|
||||
public:
|
||||
enum class PlayerSide { Left, Right };
|
||||
|
||||
static constexpr int COLS = 20;
|
||||
static constexpr int ROWS = Game::ROWS;
|
||||
static constexpr int TILE = Game::TILE;
|
||||
|
||||
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{0}; int y{-2}; };
|
||||
|
||||
struct Cell {
|
||||
int value{0}; // 0 empty else color index (1..7)
|
||||
PlayerSide owner{PlayerSide::Left};
|
||||
bool occupied{false};
|
||||
};
|
||||
|
||||
struct RowHalfState {
|
||||
bool leftFull{false};
|
||||
bool rightFull{false};
|
||||
};
|
||||
|
||||
struct PlayerState {
|
||||
PlayerSide side{PlayerSide::Left};
|
||||
Piece cur{};
|
||||
Piece hold{};
|
||||
Piece next{};
|
||||
uint64_t pieceSeq{0};
|
||||
bool canHold{true};
|
||||
bool softDropping{false};
|
||||
bool toppedOut{false};
|
||||
double fallAcc{0.0};
|
||||
double lockAcc{0.0};
|
||||
int score{0};
|
||||
int lines{0};
|
||||
int level{0};
|
||||
int tetrisesMade{0};
|
||||
int currentCombo{0};
|
||||
int maxCombo{0};
|
||||
int comboCount{0};
|
||||
std::vector<PieceType> bag{}; // 7-bag queue
|
||||
std::mt19937 rng{ std::random_device{}() };
|
||||
};
|
||||
|
||||
explicit CoopGame(int startLevel = 0);
|
||||
using SoundCallback = std::function<void(int)>;
|
||||
using LevelUpCallback = std::function<void(int)>;
|
||||
void setSoundCallback(SoundCallback cb) { soundCallback = cb; }
|
||||
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
|
||||
|
||||
void reset(int startLevel = 0);
|
||||
void resetDeterministic(int startLevel, uint32_t seed);
|
||||
void tickGravity(double frameMs);
|
||||
void updateVisualEffects(double frameMs);
|
||||
|
||||
// Determinism / desync detection
|
||||
uint64_t computeStateHash() const;
|
||||
|
||||
// Per-player inputs -----------------------------------------------------
|
||||
void setSoftDropping(PlayerSide side, bool on);
|
||||
void move(PlayerSide side, int dx);
|
||||
void rotate(PlayerSide side, int dir); // +1 cw, -1 ccw
|
||||
void hardDrop(PlayerSide side);
|
||||
void holdCurrent(PlayerSide side);
|
||||
|
||||
// Accessors -------------------------------------------------------------
|
||||
const std::array<Cell, COLS * ROWS>& boardRef() const { return board; }
|
||||
const Piece& current(PlayerSide s) const { return player(s).cur; }
|
||||
const Piece& next(PlayerSide s) const { return player(s).next; }
|
||||
const Piece& held(PlayerSide s) const { return player(s).hold; }
|
||||
bool canHold(PlayerSide s) const { return player(s).canHold; }
|
||||
bool isGameOver() const { return gameOver; }
|
||||
int score() const { return _score; }
|
||||
int score(PlayerSide s) const { return player(s).score; }
|
||||
int lines() const { return _lines; }
|
||||
int lines(PlayerSide s) const { return player(s).lines; }
|
||||
int level() const { return _level; }
|
||||
int level(PlayerSide s) const { return player(s).level; }
|
||||
int comboCount() const { return _comboCount; }
|
||||
int maxCombo() const { return _maxCombo; }
|
||||
int tetrisesMade() const { return _tetrisesMade; }
|
||||
int elapsed() const { return static_cast<int>(elapsedMs / 1000.0); }
|
||||
int elapsed(PlayerSide) const { return elapsed(); }
|
||||
int startLevelBase() const { return startLevel; }
|
||||
double getGravityMs() const { return gravityMs; }
|
||||
double getFallAccumulator(PlayerSide s) const { return player(s).fallAcc; }
|
||||
bool isSoftDropping(PlayerSide s) const { return player(s).softDropping; }
|
||||
uint64_t currentPieceSequence(PlayerSide s) const { return player(s).pieceSeq; }
|
||||
const std::vector<int>& getCompletedLines() const { return completedLines; }
|
||||
bool hasCompletedLines() const { return !completedLines.empty(); }
|
||||
void clearCompletedLines();
|
||||
const std::array<RowHalfState, ROWS>& rowHalfStates() const { return rowStates; }
|
||||
|
||||
// Simple visual-effect compatibility (stubbed for now)
|
||||
bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; }
|
||||
double hardDropShakeStrength() const;
|
||||
const std::vector<SDL_Point>& getHardDropCells() const { return hardDropCells; }
|
||||
uint32_t getHardDropFxId() const { return hardDropFxId; }
|
||||
|
||||
static bool cellFilled(const Piece& p, int cx, int cy);
|
||||
|
||||
private:
|
||||
static constexpr double LOCK_DELAY_MS = 500.0;
|
||||
|
||||
void resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt);
|
||||
|
||||
std::array<Cell, COLS * ROWS> board{};
|
||||
std::array<RowHalfState, ROWS> rowStates{};
|
||||
PlayerState left{};
|
||||
PlayerState right{ PlayerSide::Right };
|
||||
|
||||
int _score{0};
|
||||
int _lines{0};
|
||||
int _level{1};
|
||||
int _tetrisesMade{0};
|
||||
int _currentCombo{0};
|
||||
int _maxCombo{0};
|
||||
int _comboCount{0};
|
||||
int startLevel{0};
|
||||
double gravityMs{800.0};
|
||||
double gravityGlobalMultiplier{1.0};
|
||||
bool gameOver{false};
|
||||
|
||||
double elapsedMs{0.0};
|
||||
|
||||
std::vector<int> completedLines;
|
||||
|
||||
// Impact FX
|
||||
double hardDropShakeTimerMs{0.0};
|
||||
static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0;
|
||||
std::vector<SDL_Point> hardDropCells;
|
||||
uint32_t hardDropFxId{0};
|
||||
uint64_t pieceSequence{0};
|
||||
SoundCallback soundCallback;
|
||||
LevelUpCallback levelUpCallback;
|
||||
|
||||
// Helpers ---------------------------------------------------------------
|
||||
PlayerState& player(PlayerSide s) { return s == PlayerSide::Left ? left : right; }
|
||||
const PlayerState& player(PlayerSide s) const { return s == PlayerSide::Left ? left : right; }
|
||||
|
||||
void refillBag(PlayerState& ps);
|
||||
Piece drawFromBag(PlayerState& ps);
|
||||
void spawn(PlayerState& ps);
|
||||
bool collides(const PlayerState& ps, const Piece& p) const;
|
||||
bool tryMove(PlayerState& ps, int dx, int dy);
|
||||
void lock(PlayerState& ps);
|
||||
void findCompletedLines();
|
||||
void clearLinesInternal();
|
||||
void updateRowStates();
|
||||
void applyLineClearRewards(PlayerState& creditPlayer, int cleared);
|
||||
double gravityMsForLevel(int level) const;
|
||||
int columnMin(PlayerSide s) const { return s == PlayerSide::Left ? 0 : 10; }
|
||||
int columnMax(PlayerSide s) const { return s == PlayerSide::Left ? 9 : 19; }
|
||||
};
|
||||
@ -15,7 +15,7 @@ enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
|
||||
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
||||
|
||||
// Game runtime mode
|
||||
enum class GameMode { Endless, Challenge };
|
||||
enum class GameMode { Endless, Cooperate, Challenge };
|
||||
|
||||
// Special obstacle blocks used by Challenge mode
|
||||
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/AudioManager.h"
|
||||
#include "gameplay/core/Game.h"
|
||||
|
||||
#ifndef M_PI
|
||||
@ -188,10 +189,13 @@ void LineEffect::initAudio() {
|
||||
}
|
||||
}
|
||||
|
||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize) {
|
||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols, int gapPx, int gapAfterCol) {
|
||||
if (rows.empty()) return;
|
||||
|
||||
clearingRows = rows;
|
||||
effectGridCols = std::max(1, gridCols);
|
||||
effectGapPx = std::max(0, gapPx);
|
||||
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||
state = AnimationState::FLASH_WHITE;
|
||||
timer = 0.0f;
|
||||
dropProgress = 0.0f;
|
||||
@ -228,8 +232,11 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
|
||||
|
||||
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
|
||||
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
|
||||
for (int col = 0; col < Game::COLS; ++col) {
|
||||
for (int col = 0; col < effectGridCols; ++col) {
|
||||
float centerX = gridX + col * blockSize + blockSize * 0.5f;
|
||||
if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) {
|
||||
centerX += static_cast<float>(effectGapPx);
|
||||
}
|
||||
SDL_Color tint = pickFireColor();
|
||||
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
|
||||
spawnShardBurst(centerX, centerY, tint);
|
||||
@ -337,8 +344,12 @@ void LineEffect::updateGlowPulses(float dt) {
|
||||
glowPulses.end());
|
||||
}
|
||||
|
||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) {
|
||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx, int gapAfterCol) {
|
||||
if (state == AnimationState::IDLE) return;
|
||||
|
||||
// Allow caller to override gap mapping (useful for Coop renderer that inserts a mid-gap).
|
||||
effectGapPx = std::max(0, gapPx);
|
||||
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||
|
||||
switch (state) {
|
||||
case AnimationState::FLASH_WHITE:
|
||||
@ -383,10 +394,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
|
||||
|
||||
for (int row : clearingRows) {
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
|
||||
const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0;
|
||||
SDL_FRect flashRect = {
|
||||
static_cast<float>(gridX - 4),
|
||||
static_cast<float>(gridY + row * blockSize - 4),
|
||||
static_cast<float>(10 * blockSize + 8),
|
||||
static_cast<float>(effectGridCols * blockSize + gapW + 8),
|
||||
static_cast<float>(blockSize + 8)
|
||||
};
|
||||
SDL_RenderFillRect(renderer, &flashRect);
|
||||
@ -450,7 +462,7 @@ void LineEffect::playLineClearSound(int lineCount) {
|
||||
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
|
||||
if (sample && !sample->empty()) {
|
||||
// Mix via shared Audio device so it layers with music
|
||||
Audio::instance().playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
if (auto sys = AudioManager::get()) sys->playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -69,11 +69,11 @@ public:
|
||||
void shutdown();
|
||||
|
||||
// Start line clear effect for the specified rows
|
||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize);
|
||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS, int gapPx = 0, int gapAfterCol = 0);
|
||||
|
||||
// Update and render the effect
|
||||
bool update(float deltaTime); // Returns true if effect is complete
|
||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize);
|
||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx = 0, int gapAfterCol = 0);
|
||||
float getRowDropOffset(int row) const;
|
||||
|
||||
// Audio
|
||||
@ -120,4 +120,7 @@ private:
|
||||
std::array<float, Game::ROWS> rowDropTargets{};
|
||||
float dropProgress = 0.0f;
|
||||
int dropBlockSize = 0;
|
||||
int effectGridCols = Game::COLS;
|
||||
int effectGapPx = 0;
|
||||
int effectGapAfterCol = 0;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include "../../gameplay/core/Game.h"
|
||||
#include "../../gameplay/coop/CoopGame.h"
|
||||
|
||||
// Forward declarations
|
||||
class FontAtlas;
|
||||
@ -61,6 +62,24 @@ public:
|
||||
int selectedButton
|
||||
);
|
||||
|
||||
static void renderCoopPlayingState(
|
||||
SDL_Renderer* renderer,
|
||||
CoopGame* game,
|
||||
FontAtlas* pixelFont,
|
||||
LineEffect* lineEffect,
|
||||
SDL_Texture* blocksTex,
|
||||
SDL_Texture* statisticsPanelTex,
|
||||
SDL_Texture* scorePanelTex,
|
||||
SDL_Texture* nextPanelTex,
|
||||
SDL_Texture* holdPanelTex,
|
||||
bool paused,
|
||||
float logicalW,
|
||||
float logicalH,
|
||||
float logicalScale,
|
||||
float winW,
|
||||
float winH
|
||||
);
|
||||
|
||||
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
||||
// calling from non-member helper functions (e.g. visual effects) that cannot
|
||||
// access private class members.
|
||||
|
||||
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
@ -0,0 +1,358 @@
|
||||
#include "SyncLineRenderer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
SyncLineRenderer::SyncLineRenderer()
|
||||
: m_state(SyncState::Idle),
|
||||
m_flashTimer(0.0f),
|
||||
m_time(0.0f) {
|
||||
m_particles.reserve(MAX_PARTICLES);
|
||||
}
|
||||
|
||||
static float syncWobbleX(float t) {
|
||||
// Small, smooth horizontal motion to make the conduit feel fluid.
|
||||
// Kept subtle so it doesn't distract from gameplay.
|
||||
return std::sinf(t * 2.1f) * 1.25f + std::sinf(t * 5.2f + 1.3f) * 0.55f;
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SpawnParticle() {
|
||||
if (m_particles.size() >= MAX_PARTICLES) {
|
||||
return;
|
||||
}
|
||||
|
||||
SyncParticle p;
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||
// Spawn around the beam center so it reads like a conduit.
|
||||
const float jitter = -8.0f + static_cast<float>(std::rand() % 17);
|
||||
|
||||
p.x = centerX + jitter;
|
||||
p.y = m_rect.y + m_rect.h + static_cast<float>(std::rand() % 10);
|
||||
|
||||
// Two styles: tiny sparkle dots + short streaks.
|
||||
const bool dot = (std::rand() % 100) < 35;
|
||||
if (dot) {
|
||||
p.vx = (-18.0f + static_cast<float>(std::rand() % 37));
|
||||
p.vy = 180.0f + static_cast<float>(std::rand() % 180);
|
||||
p.w = 1.0f + static_cast<float>(std::rand() % 2);
|
||||
p.h = 1.0f + static_cast<float>(std::rand() % 2);
|
||||
p.alpha = 240.0f;
|
||||
} else {
|
||||
p.vx = (-14.0f + static_cast<float>(std::rand() % 29));
|
||||
p.vy = 160.0f + static_cast<float>(std::rand() % 200);
|
||||
p.w = 1.0f + static_cast<float>(std::rand() % 3);
|
||||
p.h = 3.0f + static_cast<float>(std::rand() % 10);
|
||||
p.alpha = 220.0f;
|
||||
}
|
||||
|
||||
// Slight color variance (cyan/green/white) to keep it energetic.
|
||||
const int roll = std::rand() % 100;
|
||||
if (roll < 55) {
|
||||
p.color = SDL_Color{110, 255, 210, 255};
|
||||
} else if (roll < 90) {
|
||||
p.color = SDL_Color{120, 210, 255, 255};
|
||||
} else {
|
||||
p.color = SDL_Color{255, 255, 255, 255};
|
||||
}
|
||||
|
||||
m_particles.push_back(p);
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SpawnBurst(int count) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
SpawnParticle();
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SetRect(const SDL_FRect& rect) {
|
||||
m_rect = rect;
|
||||
}
|
||||
|
||||
void SyncLineRenderer::SetState(SyncState state) {
|
||||
if (state != SyncState::ClearFlash) {
|
||||
m_state = state;
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::TriggerClearFlash() {
|
||||
m_state = SyncState::ClearFlash;
|
||||
m_flashTimer = FLASH_DURATION;
|
||||
|
||||
// Reward burst: strong visual feedback on cooperative clear.
|
||||
SpawnBurst(56);
|
||||
}
|
||||
|
||||
void SyncLineRenderer::Update(float deltaTime) {
|
||||
m_time += deltaTime;
|
||||
m_pulseTime += deltaTime;
|
||||
|
||||
// State-driven particle spawning
|
||||
float spawnRatePerSec = 0.0f;
|
||||
int particlesPerSpawn = 1;
|
||||
switch (m_state) {
|
||||
case SyncState::LeftReady:
|
||||
case SyncState::RightReady:
|
||||
spawnRatePerSec = 24.0f; // steady
|
||||
break;
|
||||
case SyncState::Synced:
|
||||
spawnRatePerSec = 78.0f; // very heavy stream
|
||||
particlesPerSpawn = 2;
|
||||
break;
|
||||
default:
|
||||
spawnRatePerSec = 18.0f; // always-on sparkle stream
|
||||
break;
|
||||
}
|
||||
|
||||
if (spawnRatePerSec <= 0.0f) {
|
||||
m_spawnAcc = 0.0f;
|
||||
} else {
|
||||
m_spawnAcc += deltaTime * spawnRatePerSec;
|
||||
while (m_spawnAcc >= 1.0f) {
|
||||
m_spawnAcc -= 1.0f;
|
||||
for (int i = 0; i < particlesPerSpawn; ++i) {
|
||||
SpawnParticle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update particles
|
||||
for (auto& p : m_particles) {
|
||||
p.x += p.vx * deltaTime;
|
||||
p.y -= p.vy * deltaTime;
|
||||
// Slow drift & fade.
|
||||
p.vx *= (1.0f - 0.35f * deltaTime);
|
||||
p.alpha -= 115.0f * deltaTime;
|
||||
}
|
||||
std::erase_if(m_particles, [&](const SyncParticle& p) {
|
||||
// Cull when out of view or too far from the beam.
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||
const float maxDx = 18.0f;
|
||||
return (p.y < (m_rect.y - 16.0f)) || p.alpha <= 0.0f || std::fabs(p.x - centerX) > maxDx;
|
||||
});
|
||||
|
||||
if (m_state == SyncState::ClearFlash) {
|
||||
m_flashTimer -= deltaTime;
|
||||
if (m_flashTimer <= 0.0f) {
|
||||
m_state = SyncState::Idle;
|
||||
m_flashTimer = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Color SyncLineRenderer::GetBaseColor() const {
|
||||
switch (m_state) {
|
||||
case SyncState::LeftReady:
|
||||
case SyncState::RightReady:
|
||||
return SDL_Color{255, 220, 100, 235};
|
||||
|
||||
case SyncState::Synced:
|
||||
return SDL_Color{100, 255, 120, 240};
|
||||
|
||||
case SyncState::ClearFlash:
|
||||
return SDL_Color{255, 255, 255, 255};
|
||||
|
||||
default:
|
||||
return SDL_Color{80, 180, 255, 235};
|
||||
}
|
||||
}
|
||||
|
||||
void SyncLineRenderer::Render(SDL_Renderer* renderer) {
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We render the conduit with lots of translucent layers. Using additive blending
|
||||
// for glow/pulse makes it read like a blurred beam without shaders.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
const float wobbleX = syncWobbleX(m_time);
|
||||
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + wobbleX;
|
||||
const float h = m_rect.h;
|
||||
const float hotspotH = std::clamp(h * 0.12f, 18.0f, 44.0f);
|
||||
|
||||
// Flash factor (0..1)
|
||||
const float flashT = (m_state == SyncState::ClearFlash && FLASH_DURATION > 0.0f)
|
||||
? std::clamp(m_flashTimer / FLASH_DURATION, 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
|
||||
SDL_Color color = GetBaseColor();
|
||||
|
||||
// Synced pulse drives aura + core intensity.
|
||||
float pulse01 = 0.0f;
|
||||
if (m_state == SyncState::Synced) {
|
||||
pulse01 = 0.5f + 0.5f * std::sinf(m_time * 6.0f);
|
||||
}
|
||||
|
||||
// 1) Outer aura layers (bloom-like using rectangles)
|
||||
auto drawGlow = [&](float extraW, Uint8 a, SDL_Color c) {
|
||||
SDL_FRect fr{
|
||||
centerX - (m_rect.w + extraW) * 0.5f,
|
||||
m_rect.y,
|
||||
m_rect.w + extraW,
|
||||
m_rect.h
|
||||
};
|
||||
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a);
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
};
|
||||
|
||||
SDL_Color aura = color;
|
||||
// Slightly bias aura towards cyan so it reads “energy conduit”.
|
||||
aura.r = static_cast<Uint8>(std::min(255, static_cast<int>(aura.r) + 10));
|
||||
aura.g = static_cast<Uint8>(std::min(255, static_cast<int>(aura.g) + 10));
|
||||
aura.b = static_cast<Uint8>(std::min(255, static_cast<int>(aura.b) + 35));
|
||||
|
||||
const float auraBoost = (m_state == SyncState::Synced) ? (0.70f + 0.80f * pulse01) : 0.70f;
|
||||
const float flashBoost = 1.0f + flashT * 1.45f;
|
||||
|
||||
SDL_BlendMode oldBlend = SDL_BLENDMODE_BLEND;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
|
||||
SDL_Color auraOuter = aura;
|
||||
auraOuter.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.r) + 10));
|
||||
auraOuter.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.g) + 5));
|
||||
auraOuter.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.b) + 55));
|
||||
|
||||
SDL_Color auraInner = aura;
|
||||
auraInner.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.r) + 40));
|
||||
auraInner.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.g) + 40));
|
||||
auraInner.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.b) + 70));
|
||||
|
||||
// Wider + softer outer halo, then tighter inner glow.
|
||||
drawGlow(62.0f, static_cast<Uint8>(std::clamp(12.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(44.0f, static_cast<Uint8>(std::clamp(20.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(30.0f, static_cast<Uint8>(std::clamp(34.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||
drawGlow(18.0f, static_cast<Uint8>(std::clamp(54.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||
drawGlow(10.0f, static_cast<Uint8>(std::clamp(78.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||
|
||||
// 2) Hotspots near top/bottom (adds that “powered endpoints” vibe)
|
||||
SDL_Color hot = auraInner;
|
||||
hot.r = static_cast<Uint8>(std::min(255, static_cast<int>(hot.r) + 35));
|
||||
hot.g = static_cast<Uint8>(std::min(255, static_cast<int>(hot.g) + 35));
|
||||
hot.b = static_cast<Uint8>(std::min(255, static_cast<int>(hot.b) + 35));
|
||||
{
|
||||
const float hotW1 = 34.0f;
|
||||
const float hotW2 = 18.0f;
|
||||
SDL_FRect topHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y, m_rect.w + hotW1, hotspotH };
|
||||
SDL_FRect botHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y + m_rect.h - hotspotH, m_rect.w + hotW1, hotspotH };
|
||||
SDL_FRect topHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + hotspotH * 0.12f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||
SDL_FRect botHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + m_rect.h - hotspotH * 0.90f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||
|
||||
Uint8 ha1 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 85.0f : 55.0f) * flashBoost, 0.0f, 255.0f));
|
||||
Uint8 ha2 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 130.0f : 90.0f) * flashBoost, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, hot.r, hot.g, hot.b, ha1);
|
||||
SDL_RenderFillRect(renderer, &topHot1);
|
||||
SDL_RenderFillRect(renderer, &botHot1);
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ha2);
|
||||
SDL_RenderFillRect(renderer, &topHot2);
|
||||
SDL_RenderFillRect(renderer, &botHot2);
|
||||
}
|
||||
|
||||
// 3) Synced pulse wave (a travelling “breath” around the beam)
|
||||
if (m_state == SyncState::Synced) {
|
||||
float wave = std::fmod(m_pulseTime * 2.4f, 1.0f);
|
||||
float width = 10.0f + wave * 26.0f;
|
||||
Uint8 alpha = static_cast<Uint8>(std::clamp(150.0f * (1.0f - wave) * flashBoost, 0.0f, 255.0f));
|
||||
|
||||
SDL_FRect waveRect{
|
||||
centerX - (m_rect.w + width) * 0.5f,
|
||||
m_rect.y,
|
||||
m_rect.w + width,
|
||||
m_rect.h
|
||||
};
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, 140, 255, 220, alpha);
|
||||
SDL_RenderFillRect(renderer, &waveRect);
|
||||
}
|
||||
|
||||
// 4) Shimmer bands (stylish motion inside the conduit)
|
||||
{
|
||||
const int bands = 7;
|
||||
const float speed = (m_state == SyncState::Synced) ? 160.0f : 95.0f;
|
||||
const float bandW = m_rect.w + 12.0f;
|
||||
for (int i = 0; i < bands; ++i) {
|
||||
const float phase = (static_cast<float>(i) / static_cast<float>(bands));
|
||||
const float y = m_rect.y + std::fmod(m_time * speed + phase * h, h);
|
||||
const float fade = 0.35f + 0.65f * std::sinf((m_time * 2.1f) + phase * 6.28318f);
|
||||
const float bandH = 2.0f + (phase * 2.0f);
|
||||
Uint8 a = static_cast<Uint8>(std::clamp((26.0f + 36.0f * pulse01) * std::fabs(fade) * flashBoost, 0.0f, 255.0f));
|
||||
SDL_FRect fr{ centerX - bandW * 0.5f, y, bandW, bandH };
|
||||
SDL_SetRenderDrawColor(renderer, 200, 255, 255, a);
|
||||
SDL_RenderFillRect(renderer, &fr);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Core beam (thin bright core + thicker body with horizontal gradient)
|
||||
Uint8 bodyA = color.a;
|
||||
if (m_state == SyncState::Synced) {
|
||||
bodyA = static_cast<Uint8>(std::clamp(175.0f + pulse01 * 75.0f, 0.0f, 255.0f));
|
||||
}
|
||||
// Keep the center more translucent; let glow carry intensity.
|
||||
bodyA = static_cast<Uint8>(std::clamp(bodyA * (0.72f + flashT * 0.35f), 0.0f, 255.0f));
|
||||
|
||||
// Render a smooth-looking body by stacking a few vertical strips.
|
||||
// This approximates a gradient (bright center -> soft edges) without shaders.
|
||||
{
|
||||
// Allow thinner beam while keeping gradient readable.
|
||||
const float bodyW = std::max(4.0f, m_rect.w);
|
||||
const float x0 = centerX - bodyW * 0.5f;
|
||||
|
||||
SDL_FRect left{ x0, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||
SDL_FRect mid{ x0 + bodyW * 0.34f, m_rect.y, bodyW * 0.32f, m_rect.h };
|
||||
SDL_FRect right{ x0 + bodyW * 0.66f, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, static_cast<Uint8>(std::clamp(bodyA * 0.60f, 0.0f, 255.0f)));
|
||||
SDL_RenderFillRect(renderer, &left);
|
||||
SDL_RenderFillRect(renderer, &right);
|
||||
|
||||
SDL_SetRenderDrawColor(renderer,
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.r) + 35)),
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.g) + 35)),
|
||||
static_cast<Uint8>(std::min(255, static_cast<int>(color.b) + 55)),
|
||||
static_cast<Uint8>(std::clamp(bodyA * 0.88f, 0.0f, 255.0f)));
|
||||
SDL_RenderFillRect(renderer, &mid);
|
||||
}
|
||||
|
||||
SDL_FRect coreRect{ centerX - 1.1f, m_rect.y, 2.2f, m_rect.h };
|
||||
Uint8 coreA = static_cast<Uint8>(std::clamp(210.0f + pulse01 * 70.0f + flashT * 95.0f, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, coreA);
|
||||
SDL_RenderFillRect(renderer, &coreRect);
|
||||
|
||||
// Switch back to normal alpha blend for particles so they stay readable.
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
// 6) Energy particles (sparks/streaks traveling upward)
|
||||
for (const auto& p : m_particles) {
|
||||
Uint8 a = static_cast<Uint8>(std::clamp(p.alpha, 0.0f, 255.0f));
|
||||
|
||||
// Add a tiny sinusoidal sway so the stream feels alive.
|
||||
const float sway = std::sinf((p.y * 0.045f) + (m_time * 6.2f)) * 0.9f;
|
||||
SDL_FRect spark{ (p.x + sway) - (p.w * 0.5f), p.y, p.w, p.h };
|
||||
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, a);
|
||||
SDL_RenderFillRect(renderer, &spark);
|
||||
|
||||
// A little aura around each spark helps it read at speed.
|
||||
if (a > 40) {
|
||||
SDL_FRect sparkGlow{ spark.x - 1.0f, spark.y - 1.0f, spark.w + 2.0f, spark.h + 2.0f };
|
||||
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, static_cast<Uint8>(a * 0.35f));
|
||||
SDL_RenderFillRect(renderer, &sparkGlow);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Flash/glow overlay (adds “clear burst” punch)
|
||||
if (m_state == SyncState::ClearFlash) {
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||
|
||||
const float extra = 74.0f;
|
||||
SDL_FRect glow{ centerX - (m_rect.w + extra) * 0.5f, m_rect.y, m_rect.w + extra, m_rect.h };
|
||||
Uint8 ga = static_cast<Uint8>(std::clamp(90.0f + 140.0f * flashT, 0.0f, 255.0f));
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ga);
|
||||
SDL_RenderFillRect(renderer, &glow);
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
|
||||
// Restore whatever blend mode the caller had.
|
||||
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||
}
|
||||
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
enum class SyncState {
|
||||
Idle,
|
||||
LeftReady,
|
||||
RightReady,
|
||||
Synced,
|
||||
ClearFlash
|
||||
};
|
||||
|
||||
class SyncLineRenderer {
|
||||
public:
|
||||
SyncLineRenderer();
|
||||
|
||||
void SetRect(const SDL_FRect& rect);
|
||||
void SetState(SyncState state);
|
||||
void TriggerClearFlash();
|
||||
|
||||
void Update(float deltaTime);
|
||||
void Render(SDL_Renderer* renderer);
|
||||
|
||||
private:
|
||||
struct SyncParticle {
|
||||
float x;
|
||||
float y;
|
||||
float vx;
|
||||
float vy;
|
||||
float w;
|
||||
float h;
|
||||
float alpha;
|
||||
SDL_Color color;
|
||||
};
|
||||
|
||||
SDL_FRect m_rect{};
|
||||
SyncState m_state;
|
||||
|
||||
float m_flashTimer;
|
||||
float m_time;
|
||||
|
||||
float m_pulseTime{0.0f};
|
||||
float m_spawnAcc{0.0f};
|
||||
std::vector<SyncParticle> m_particles;
|
||||
|
||||
static constexpr float FLASH_DURATION = 0.15f;
|
||||
static constexpr size_t MAX_PARTICLES = 240;
|
||||
|
||||
void SpawnParticle();
|
||||
void SpawnBurst(int count);
|
||||
|
||||
SDL_Color GetBaseColor() const;
|
||||
};
|
||||
@ -232,6 +232,6 @@ void UIRenderer::drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, floa
|
||||
|
||||
// Instructions
|
||||
font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||
font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
||||
{"ESC", "Back / cancel current popup"},
|
||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||
{"M", "Mute or unmute music"},
|
||||
{"S", "Toggle sound effects"}
|
||||
{"K", "Toggle sound effects"}
|
||||
}};
|
||||
|
||||
const std::array<ShortcutEntry, 2> menuShortcuts{{
|
||||
|
||||
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;
|
||||
};
|
||||
182
src/network/supabase_client.cpp
Normal file
182
src/network/supabase_client.cpp
Normal file
@ -0,0 +1,182 @@
|
||||
#include "supabase_client.h"
|
||||
#include <curl/curl.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
// Supabase constants (publishable anon key)
|
||||
const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co";
|
||||
const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA";
|
||||
|
||||
std::string buildUrl(const std::string &path) {
|
||||
std::string url = SUPABASE_URL;
|
||||
if (!url.empty() && url.back() == '/') url.pop_back();
|
||||
url += "/rest/v1/" + path;
|
||||
return url;
|
||||
}
|
||||
|
||||
size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
size_t realSize = size * nmemb;
|
||||
std::string *s = reinterpret_cast<std::string*>(userp);
|
||||
s->append(reinterpret_cast<char*>(contents), realSize);
|
||||
return realSize;
|
||||
}
|
||||
|
||||
struct CurlInit {
|
||||
CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); }
|
||||
~CurlInit() { curl_global_cleanup(); }
|
||||
};
|
||||
static CurlInit g_curl_init;
|
||||
}
|
||||
|
||||
namespace supabase {
|
||||
|
||||
static bool g_verbose = false;
|
||||
|
||||
void SetVerbose(bool enabled) {
|
||||
g_verbose = enabled;
|
||||
}
|
||||
|
||||
|
||||
void SubmitHighscoreAsync(const ScoreEntry &entry) {
|
||||
std::thread([entry]() {
|
||||
try {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return;
|
||||
|
||||
std::string url = buildUrl("highscores");
|
||||
|
||||
json j;
|
||||
j["score"] = entry.score;
|
||||
j["lines"] = entry.lines;
|
||||
j["level"] = entry.level;
|
||||
j["time_sec"] = static_cast<int>(std::lround(entry.timeSec));
|
||||
j["name"] = entry.name;
|
||||
j["game_type"] = entry.gameType;
|
||||
j["timestamp"] = static_cast<int>(std::time(nullptr));
|
||||
|
||||
std::string body = j.dump();
|
||||
struct curl_slist *headers = nullptr;
|
||||
std::string h1 = std::string("apikey: ") + SUPABASE_ANON_KEY;
|
||||
std::string h2 = std::string("Authorization: Bearer ") + SUPABASE_ANON_KEY;
|
||||
headers = curl_slist_append(headers, h1.c_str());
|
||||
headers = curl_slist_append(headers, h2.c_str());
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
std::string resp;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
|
||||
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] POST " << url << "\n";
|
||||
std::cerr << "[Supabase] Body: " << body << "\n";
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
if (res != CURLE_OK) {
|
||||
if (g_verbose) std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n";
|
||||
} else {
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] POST response code: " << http_code << " body_len=" << resp.size() << "\n";
|
||||
if (!resp.empty()) std::cerr << "[Supabase] POST response: " << resp << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
} catch (...) {
|
||||
// swallow errors
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
|
||||
std::vector<ScoreEntry> FetchHighscores(const std::string &gameType, int limit) {
|
||||
std::vector<ScoreEntry> out;
|
||||
try {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return out;
|
||||
|
||||
std::string path = "highscores";
|
||||
// Clamp limit to max 10 to keep payloads small
|
||||
int l = std::clamp(limit, 1, 10);
|
||||
std::string query;
|
||||
if (!gameType.empty()) {
|
||||
if (gameType == "challenge") {
|
||||
query = "?game_type=eq." + gameType + "&order=level.desc,time_sec.asc&limit=" + std::to_string(l);
|
||||
} else {
|
||||
query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(l);
|
||||
}
|
||||
} else {
|
||||
query = "?order=score.desc&limit=" + std::to_string(l);
|
||||
}
|
||||
|
||||
std::string url = buildUrl(path) + query;
|
||||
|
||||
struct curl_slist *headers = nullptr;
|
||||
headers = curl_slist_append(headers, ("apikey: " + SUPABASE_ANON_KEY).c_str());
|
||||
headers = curl_slist_append(headers, ("Authorization: Bearer " + SUPABASE_ANON_KEY).c_str());
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
|
||||
std::string resp;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
|
||||
|
||||
if (g_verbose) std::cerr << "[Supabase] GET " << url << "\n";
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
if (res == CURLE_OK) {
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
if (g_verbose) {
|
||||
std::cerr << "[Supabase] GET response code: " << http_code << " body_len=" << resp.size() << "\n";
|
||||
if (!resp.empty()) std::cerr << "[Supabase] GET response: " << resp << "\n";
|
||||
}
|
||||
try {
|
||||
auto j = json::parse(resp);
|
||||
if (j.is_array()) {
|
||||
for (auto &v : j) {
|
||||
ScoreEntry e{};
|
||||
if (v.contains("score")) e.score = v["score"].get<int>();
|
||||
if (v.contains("lines")) e.lines = v["lines"].get<int>();
|
||||
if (v.contains("level")) e.level = v["level"].get<int>();
|
||||
if (v.contains("time_sec")) {
|
||||
try { e.timeSec = v["time_sec"].get<double>(); } catch(...) { e.timeSec = v["time_sec"].get<int>(); }
|
||||
} else if (v.contains("timestamp")) {
|
||||
e.timeSec = v["timestamp"].get<int>();
|
||||
}
|
||||
if (v.contains("name")) e.name = v["name"].get<std::string>();
|
||||
if (v.contains("game_type")) e.gameType = v["game_type"].get<std::string>();
|
||||
out.push_back(e);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
if (g_verbose) std::cerr << "[Supabase] GET parse error" << std::endl;
|
||||
}
|
||||
} else {
|
||||
if (g_verbose) std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n";
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
} catch (...) {
|
||||
// swallow
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace supabase
|
||||
17
src/network/supabase_client.h
Normal file
17
src/network/supabase_client.h
Normal file
@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../persistence/Scores.h"
|
||||
|
||||
namespace supabase {
|
||||
|
||||
// Submit a highscore asynchronously (detached thread)
|
||||
void SubmitHighscoreAsync(const ScoreEntry &entry);
|
||||
|
||||
// Fetch highscores for a game type. If gameType is empty, fetch all (limited).
|
||||
std::vector<ScoreEntry> FetchHighscores(const std::string &gameType, int limit);
|
||||
|
||||
// Enable or disable verbose logging to stderr. Disabled by default.
|
||||
void SetVerbose(bool enabled);
|
||||
|
||||
} // namespace supabase
|
||||
@ -1,20 +1,18 @@
|
||||
// Scores.cpp - Implementation of ScoreManager with Firebase Sync
|
||||
// Scores.cpp - Implementation of ScoreManager
|
||||
#include "Scores.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cpr/cpr.h>
|
||||
#include "../network/supabase_client.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// Firebase Realtime Database URL
|
||||
const std::string FIREBASE_URL = "https://tetris-90139.firebaseio.com/scores.json";
|
||||
|
||||
ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {}
|
||||
|
||||
std::string ScoreManager::filePath() const {
|
||||
@ -27,48 +25,19 @@ std::string ScoreManager::filePath() const {
|
||||
void ScoreManager::load() {
|
||||
scores.clear();
|
||||
|
||||
// Try to load from Firebase first
|
||||
// Try to load from Supabase first
|
||||
try {
|
||||
cpr::Response r = cpr::Get(cpr::Url{FIREBASE_URL}, cpr::Timeout{2000}); // 2s timeout
|
||||
if (r.status_code == 200 && !r.text.empty() && r.text != "null") {
|
||||
auto j = json::parse(r.text);
|
||||
|
||||
// Firebase returns a map of auto-generated IDs to objects
|
||||
if (j.is_object()) {
|
||||
for (auto& [key, value] : j.items()) {
|
||||
ScoreEntry e;
|
||||
if (value.contains("score")) e.score = value["score"];
|
||||
if (value.contains("lines")) e.lines = value["lines"];
|
||||
if (value.contains("level")) e.level = value["level"];
|
||||
if (value.contains("timeSec")) e.timeSec = value["timeSec"];
|
||||
if (value.contains("name")) e.name = value["name"];
|
||||
scores.push_back(e);
|
||||
}
|
||||
}
|
||||
// Or it might be an array if keys are integers (unlikely for Firebase push)
|
||||
else if (j.is_array()) {
|
||||
for (auto& value : j) {
|
||||
ScoreEntry e;
|
||||
if (value.contains("score")) e.score = value["score"];
|
||||
if (value.contains("lines")) e.lines = value["lines"];
|
||||
if (value.contains("level")) e.level = value["level"];
|
||||
if (value.contains("timeSec")) e.timeSec = value["timeSec"];
|
||||
if (value.contains("name")) e.name = value["name"];
|
||||
scores.push_back(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and keep top scores
|
||||
// Request only 10 records from Supabase to keep payload small
|
||||
auto fetched = supabase::FetchHighscores("", 10);
|
||||
if (!fetched.empty()) {
|
||||
scores = fetched;
|
||||
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||
if (scores.size() > maxEntries) scores.resize(maxEntries);
|
||||
|
||||
// Save to local cache
|
||||
save();
|
||||
return;
|
||||
}
|
||||
} catch (...) {
|
||||
// Ignore network errors and fall back to local file
|
||||
std::cerr << "Failed to load from Firebase, falling back to local file." << std::endl;
|
||||
std::cerr << "Failed to load from Supabase, falling back to local file." << std::endl;
|
||||
}
|
||||
|
||||
// Fallback to local file
|
||||
@ -86,11 +55,32 @@ void ScoreManager::load() {
|
||||
ScoreEntry e;
|
||||
iss >> e.score >> e.lines >> e.level >> e.timeSec;
|
||||
if (iss) {
|
||||
// Try to read name (rest of line after timeSec)
|
||||
// Try to read name (rest of line after timeSec). We may also have a trailing gameType token.
|
||||
std::string remaining;
|
||||
std::getline(iss, remaining);
|
||||
if (!remaining.empty() && remaining[0] == ' ') {
|
||||
e.name = remaining.substr(1); // Remove leading space
|
||||
if (!remaining.empty() && remaining[0] == ' ') remaining = remaining.substr(1);
|
||||
if (!remaining.empty()) {
|
||||
static const std::vector<std::string> known = {"classic","cooperate","challenge","versus"};
|
||||
while (!remaining.empty() && (remaining.back() == '\n' || remaining.back() == '\r' || remaining.back() == ' ')) remaining.pop_back();
|
||||
size_t lastSpace = remaining.find_last_of(' ');
|
||||
std::string lastToken = (lastSpace == std::string::npos) ? remaining : remaining.substr(lastSpace + 1);
|
||||
bool matched = false;
|
||||
for (const auto &k : known) {
|
||||
if (lastToken == k) {
|
||||
matched = true;
|
||||
e.gameType = k;
|
||||
if (lastSpace == std::string::npos) e.name = "PLAYER";
|
||||
else e.name = remaining.substr(0, lastSpace);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
e.name = remaining;
|
||||
e.gameType = "classic";
|
||||
}
|
||||
} else {
|
||||
e.name = "PLAYER";
|
||||
e.gameType = "classic";
|
||||
}
|
||||
scores.push_back(e);
|
||||
}
|
||||
@ -108,42 +98,28 @@ void ScoreManager::load() {
|
||||
void ScoreManager::save() const {
|
||||
std::ofstream f(filePath(), std::ios::trunc);
|
||||
for (auto &e : scores) {
|
||||
f << e.score << ' ' << e.lines << ' ' << e.level << ' ' << e.timeSec << ' ' << e.name << '\n';
|
||||
// Save gameType as trailing token so future loads can preserve it
|
||||
f << e.score << ' ' << e.lines << ' ' << e.level << ' ' << e.timeSec << ' ' << e.name << ' ' << e.gameType << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name) {
|
||||
void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name, const std::string& gameType) {
|
||||
// Add to local list
|
||||
scores.push_back(ScoreEntry{score,lines,level,timeSec, name});
|
||||
ScoreEntry newEntry{};
|
||||
newEntry.score = score;
|
||||
newEntry.lines = lines;
|
||||
newEntry.level = level;
|
||||
newEntry.timeSec = timeSec;
|
||||
newEntry.name = name;
|
||||
// preserve the game type locally so menu filtering works immediately
|
||||
newEntry.gameType = gameType;
|
||||
scores.push_back(newEntry);
|
||||
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||
if (scores.size()>maxEntries) scores.resize(maxEntries);
|
||||
save();
|
||||
|
||||
// Submit to Firebase
|
||||
// Run in a detached thread to avoid blocking the UI?
|
||||
// For simplicity, we'll do it blocking for now, or rely on short timeout.
|
||||
// Ideally this should be async.
|
||||
|
||||
json j;
|
||||
j["score"] = score;
|
||||
j["lines"] = lines;
|
||||
j["level"] = level;
|
||||
j["timeSec"] = timeSec;
|
||||
j["name"] = name;
|
||||
j["timestamp"] = std::time(nullptr); // Add timestamp
|
||||
|
||||
// Fire and forget (async) would be better, but for now let's just try to send
|
||||
// We can use std::thread to make it async
|
||||
std::thread([j]() {
|
||||
try {
|
||||
cpr::Post(cpr::Url{FIREBASE_URL},
|
||||
cpr::Body{j.dump()},
|
||||
cpr::Header{{"Content-Type", "application/json"}},
|
||||
cpr::Timeout{5000});
|
||||
} catch (...) {
|
||||
// Ignore errors
|
||||
}
|
||||
}).detach();
|
||||
// Submit to Supabase asynchronously
|
||||
ScoreEntry se{score, lines, level, timeSec, name, gameType};
|
||||
supabase::SubmitHighscoreAsync(se);
|
||||
}
|
||||
|
||||
bool ScoreManager::isHighScore(int score) const {
|
||||
@ -151,19 +127,28 @@ bool ScoreManager::isHighScore(int score) const {
|
||||
return score > scores.back().score;
|
||||
}
|
||||
|
||||
void ScoreManager::replaceAll(const std::vector<ScoreEntry>& newScores) {
|
||||
scores = newScores;
|
||||
// Ensure ordering and trimming to our configured maxEntries
|
||||
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||
if (scores.size() > maxEntries) scores.resize(maxEntries);
|
||||
// Persist new set to local file for next launch
|
||||
try { save(); } catch (...) { /* swallow */ }
|
||||
}
|
||||
|
||||
void ScoreManager::createSampleScores() {
|
||||
scores = {
|
||||
{159840, 189, 14, 972, "GREGOR"},
|
||||
{156340, 132, 12, 714, "GREGOR"},
|
||||
{155219, 125, 12, 696, "GREGOR"},
|
||||
{141823, 123, 10, 710, "GREGOR"},
|
||||
{140079, 71, 11, 410, "GREGOR"},
|
||||
{116012, 121, 10, 619, "GREGOR"},
|
||||
{112643, 137, 13, 689, "GREGOR"},
|
||||
{99190, 61, 10, 378, "GREGOR"},
|
||||
{93648, 107, 10, 629, "GREGOR"},
|
||||
{89041, 115, 10, 618, "GREGOR"},
|
||||
{88600, 55, 9, 354, "GREGOR"},
|
||||
{86346, 141, 13, 723, "GREGOR"}
|
||||
{159840, 189, 14, 972.0, "GREGOR"},
|
||||
{156340, 132, 12, 714.0, "GREGOR"},
|
||||
{155219, 125, 12, 696.0, "GREGOR"},
|
||||
{141823, 123, 10, 710.0, "GREGOR"},
|
||||
{140079, 71, 11, 410.0, "GREGOR"},
|
||||
{116012, 121, 10, 619.0, "GREGOR"},
|
||||
{112643, 137, 13, 689.0, "GREGOR"},
|
||||
{99190, 61, 10, 378.0, "GREGOR"},
|
||||
{93648, 107, 10, 629.0, "GREGOR"},
|
||||
{89041, 115, 10, 618.0, "GREGOR"},
|
||||
{88600, 55, 9, 354.0, "GREGOR"},
|
||||
{86346, 141, 13, 723.0, "GREGOR"}
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
struct ScoreEntry { int score{}; int lines{}; int level{}; double timeSec{}; std::string name{"PLAYER"}; };
|
||||
struct ScoreEntry { int score{}; int lines{}; int level{}; double timeSec{}; std::string name{"PLAYER"}; std::string gameType{"classic"}; };
|
||||
|
||||
class ScoreManager {
|
||||
public:
|
||||
explicit ScoreManager(size_t maxScores = 12);
|
||||
void load();
|
||||
void save() const;
|
||||
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER");
|
||||
// Replace the in-memory scores (thread-safe caller should ensure non-blocking)
|
||||
void replaceAll(const std::vector<ScoreEntry>& newScores);
|
||||
// New optional `gameType` parameter will be sent as `game_type`.
|
||||
// Allowed values: "classic", "versus", "cooperate", "challenge".
|
||||
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER", const std::string& gameType = "classic");
|
||||
bool isHighScore(int score) const;
|
||||
const std::vector<ScoreEntry>& all() const { return scores; }
|
||||
private:
|
||||
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,12 @@
|
||||
#pragma once
|
||||
#include "State.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
class NetSession;
|
||||
|
||||
class MenuState : public State {
|
||||
public:
|
||||
MenuState(StateContext& ctx);
|
||||
@ -19,9 +25,13 @@ public:
|
||||
void showHelpPanel(bool show);
|
||||
// Show or hide the inline ABOUT panel (menu-style)
|
||||
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:
|
||||
int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = HELP, 4 = ABOUT, 5 = EXIT
|
||||
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT
|
||||
|
||||
// Button icons (optional - will use text if nullptr)
|
||||
SDL_Texture* playIcon = nullptr;
|
||||
@ -94,4 +104,37 @@ private:
|
||||
double aboutTransition = 0.0; // 0..1
|
||||
double aboutTransitionDurationMs = 360.0;
|
||||
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 "../graphics/ui/Font.h"
|
||||
#include "../audio/Audio.h"
|
||||
#include "../audio/AudioManager.h"
|
||||
#include "../audio/SoundEffect.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <algorithm>
|
||||
@ -220,7 +221,7 @@ void OptionsState::toggleFullscreen() {
|
||||
}
|
||||
|
||||
void OptionsState::toggleMusic() {
|
||||
Audio::instance().toggleMute();
|
||||
if (auto sys = AudioManager::get()) sys->toggleMute();
|
||||
// If muted, music is disabled. If not muted, music is enabled.
|
||||
// Note: Audio::instance().isMuted() returns true if muted.
|
||||
// But Audio class doesn't expose isMuted directly in header usually?
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
#include "PlayingState.h"
|
||||
#include "../core/state/StateManager.h"
|
||||
#include "../gameplay/core/Game.h"
|
||||
#include "../gameplay/coop/CoopGame.h"
|
||||
#include "../gameplay/effects/LineEffect.h"
|
||||
#include "../persistence/Scores.h"
|
||||
#include "../audio/Audio.h"
|
||||
#include "../audio/SoundEffect.h"
|
||||
#include "../graphics/Font.h"
|
||||
#include "../graphics/renderers/GameRenderer.h"
|
||||
#include "../core/Settings.h"
|
||||
#include "../core/Config.h"
|
||||
#include "../network/CoopNetButtons.h"
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
// File-scope transport/spawn detection state
|
||||
@ -18,12 +21,23 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
|
||||
|
||||
void PlayingState::onEnter() {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
|
||||
// Initialize the game based on mode: endless uses chosen start level, challenge keeps its run state
|
||||
// Initialize the game based on mode: endless/cooperate use chosen start level, challenge keeps its run state
|
||||
if (ctx.game) {
|
||||
if (ctx.game->getMode() == GameMode::Endless) {
|
||||
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
|
||||
if (ctx.startLevelSelection) {
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession;
|
||||
|
||||
// For network co-op, MenuState already performed a deterministic reset using the negotiated seed.
|
||||
// Re-resetting here would overwrite it (and will desync).
|
||||
if (!coopNetActive) {
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
||||
ctx.coopGame->reset(*ctx.startLevelSelection);
|
||||
}
|
||||
} else {
|
||||
ctx.game->setPaused(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
||||
@ -42,127 +56,248 @@ void PlayingState::onExit() {
|
||||
SDL_DestroyTexture(m_renderTarget);
|
||||
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) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
// If a transport animation is active, ignore gameplay input entirely.
|
||||
if (GameRenderer::isTransportActive()) {
|
||||
return;
|
||||
}
|
||||
// We keep short-circuited input here; main still owns mouse UI
|
||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
auto setExitSelection = [&](int value) {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = value;
|
||||
}
|
||||
};
|
||||
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||
|
||||
auto getExitSelection = [&]() -> int {
|
||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||
};
|
||||
auto setExitSelection = [&](int idx) {
|
||||
if (ctx.exitPopupSelectedButton) {
|
||||
*ctx.exitPopupSelectedButton = idx;
|
||||
}
|
||||
};
|
||||
auto getExitSelection = [&]() -> int {
|
||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||
};
|
||||
|
||||
// Pause toggle (P)
|
||||
if (e.key.scancode == SDL_SCANCODE_P) {
|
||||
bool paused = ctx.game->isPaused();
|
||||
ctx.game->setPaused(!paused);
|
||||
if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If exit-confirm popup is visible, handle shortcuts here
|
||||
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
||||
// Navigate between YES (0) and NO (1) buttons
|
||||
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
||||
setExitSelection(0);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// If exit-confirm popup is visible, handle shortcuts here
|
||||
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
||||
// Navigate between YES (0) and NO (1) buttons
|
||||
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
||||
setExitSelection(0);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate selected button with Enter or Space
|
||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
const bool confirmExit = (getExitSelection() == 0);
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
if (confirmExit) {
|
||||
// YES - Reset game and return to menu
|
||||
if (ctx.startLevelSelection) {
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
} else {
|
||||
ctx.game->reset(0);
|
||||
}
|
||||
ctx.game->setPaused(false);
|
||||
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
||||
// Activate selected button with Enter or Space
|
||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
const bool confirmExit = (getExitSelection() == 0);
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
if (confirmExit) {
|
||||
// YES - Reset game and return to menu
|
||||
if (ctx.startLevelSelection) {
|
||||
ctx.game->reset(*ctx.startLevelSelection);
|
||||
} else {
|
||||
// NO - Just close popup and resume
|
||||
ctx.game->setPaused(false);
|
||||
ctx.game->reset(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cancel with Esc (same as NO)
|
||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
ctx.game->setPaused(false);
|
||||
setExitSelection(1);
|
||||
return;
|
||||
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
||||
} else {
|
||||
// NO - Just close popup and resume
|
||||
ctx.game->setPaused(false);
|
||||
}
|
||||
// While modal is open, suppress other gameplay keys
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC key - open confirmation popup
|
||||
// Cancel with Esc (same as NO)
|
||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
if (ctx.game) ctx.game->setPaused(true);
|
||||
*ctx.showExitConfirmPopup = true;
|
||||
setExitSelection(1); // Default to NO for safety
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: skip to next challenge level (B)
|
||||
if (e.key.scancode == SDL_SCANCODE_B && ctx.game && ctx.game->getMode() == GameMode::Challenge) {
|
||||
ctx.game->beginNextChallengeLevel();
|
||||
// Cancel any countdown so play resumes immediately on the new level
|
||||
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||||
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||||
*ctx.showExitConfirmPopup = false;
|
||||
ctx.game->setPaused(false);
|
||||
setExitSelection(1);
|
||||
return;
|
||||
}
|
||||
// While modal is open, suppress other gameplay keys
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC key - open confirmation popup
|
||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
if (ctx.showExitConfirmPopup) {
|
||||
ctx.game->setPaused(true);
|
||||
*ctx.showExitConfirmPopup = true;
|
||||
setExitSelection(1); // Default to NO for safety
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: skip to next challenge level (B)
|
||||
if (e.key.scancode == SDL_SCANCODE_B && ctx.game->getMode() == GameMode::Challenge) {
|
||||
ctx.game->beginNextChallengeLevel();
|
||||
// Cancel any countdown so play resumes immediately on the new level
|
||||
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||||
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||||
ctx.game->setPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause toggle (P) - matches classic behavior; disabled during countdown
|
||||
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) ||
|
||||
(ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed);
|
||||
if (!countdown) {
|
||||
ctx.game->setPaused(!ctx.game->isPaused());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tetris controls (only when not paused)
|
||||
if (ctx.game->isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (coopActive && ctx.coopGame) {
|
||||
// Network co-op: route one-shot actions into a pending bitmask for lockstep.
|
||||
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||
const bool localIsLeft = ctx.coopNetLocalIsLeft;
|
||||
const SDL_Scancode sc = e.key.scancode;
|
||||
if (localIsLeft) {
|
||||
if (sc == SDL_SCANCODE_W) {
|
||||
ctx.coopNetPendingButtons |= coopnet::RotCW;
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_Q) {
|
||||
ctx.coopNetPendingButtons |= coopnet::RotCCW;
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_LSHIFT || sc == SDL_SCANCODE_E) {
|
||||
ctx.coopNetPendingButtons |= coopnet::HardDrop;
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_LCTRL) {
|
||||
ctx.coopNetPendingButtons |= coopnet::Hold;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (sc == SDL_SCANCODE_UP) {
|
||||
const bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.coopNetPendingButtons |= upIsCW ? coopnet::RotCW : coopnet::RotCCW;
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_RALT) {
|
||||
ctx.coopNetPendingButtons |= coopnet::RotCCW;
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_SPACE || sc == SDL_SCANCODE_RSHIFT) {
|
||||
ctx.coopNetPendingButtons |= coopnet::HardDrop;
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_RCTRL) {
|
||||
ctx.coopNetPendingButtons |= coopnet::Hold;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If coopNet is active, suppress local co-op direct action keys.
|
||||
}
|
||||
|
||||
const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
|
||||
|
||||
// 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) {
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_Q) {
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1);
|
||||
return;
|
||||
}
|
||||
// Hard drop (left): keep LSHIFT, also allow E for convenience.
|
||||
if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_LCTRL) {
|
||||
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RALT) {
|
||||
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, -1);
|
||||
return;
|
||||
}
|
||||
// Hard drop (right): SPACE is the primary key for arrow controls; keep RSHIFT as an alternate.
|
||||
if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
|
||||
if (coopAIEnabled) {
|
||||
// Mirror human-initiated hard-drop to AI on left
|
||||
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
|
||||
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Right);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Single-player classic controls
|
||||
// Hold / swap current piece (H)
|
||||
if (e.key.scancode == SDL_SCANCODE_H) {
|
||||
ctx.game->holdCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tetris controls (only when not paused)
|
||||
if (!ctx.game->isPaused()) {
|
||||
// Hold / swap current piece (H)
|
||||
if (e.key.scancode == SDL_SCANCODE_H) {
|
||||
ctx.game->holdCurrent();
|
||||
return;
|
||||
}
|
||||
// Rotation (still event-based for precise timing)
|
||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||
// Use user setting to determine whether UP rotates clockwise
|
||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.game->rotate(upIsCW ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_X) {
|
||||
// Toggle the mapping so UP will rotate in the opposite direction
|
||||
bool current = Settings::instance().isUpRotateClockwise();
|
||||
Settings::instance().setUpRotateClockwise(!current);
|
||||
Settings::instance().save();
|
||||
// Play a subtle feedback sound if available
|
||||
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rotation (still event-based for precise timing)
|
||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||
// Use user setting to determine whether UP rotates clockwise
|
||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||
ctx.game->rotate(upIsCW ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key.scancode == SDL_SCANCODE_X) {
|
||||
// Toggle the mapping so UP will rotate in the opposite direction
|
||||
bool current = Settings::instance().isUpRotateClockwise();
|
||||
Settings::instance().setUpRotateClockwise(!current);
|
||||
Settings::instance().save();
|
||||
// Play a subtle feedback sound if available
|
||||
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard drop (space)
|
||||
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.game->hardDrop();
|
||||
return;
|
||||
}
|
||||
// Hard drop (space)
|
||||
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||
ctx.game->hardDrop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,7 +307,21 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
||||
|
||||
void PlayingState::update(double frameMs) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
|
||||
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||
|
||||
if (coopActive) {
|
||||
// Visual effects only; gravity and movement handled from ApplicationManager for coop
|
||||
ctx.coopGame->updateVisualEffects(frameMs);
|
||||
// Update line clear effect for coop mode as well (renderer starts the effect)
|
||||
if (ctx.lineEffect && ctx.lineEffect->isActive()) {
|
||||
if (ctx.lineEffect->update(frameMs / 1000.0f)) {
|
||||
ctx.coopGame->clearCompletedLines();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.game->updateVisualEffects(frameMs);
|
||||
// If a transport animation is active, pause gameplay updates and ignore inputs
|
||||
if (GameRenderer::isTransportActive()) {
|
||||
@ -204,6 +353,8 @@ void PlayingState::update(double frameMs) {
|
||||
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||
if (!ctx.game) return;
|
||||
|
||||
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||
|
||||
// Get current window size
|
||||
int winW = 0, winH = 0;
|
||||
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
||||
@ -231,6 +382,31 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
// But countdown should definitely NOT show the "PAUSED" overlay.
|
||||
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) {
|
||||
// Render game to texture
|
||||
SDL_SetRenderTarget(renderer, m_renderTarget);
|
||||
@ -244,26 +420,45 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
// Render game content (no overlays)
|
||||
// If a transport effect was requested due to a recent spawn, start it here so
|
||||
// the renderer has the correct layout and renderer context to compute coords.
|
||||
if (s_pendingTransport) {
|
||||
if (!coopActive && s_pendingTransport) {
|
||||
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||
s_pendingTransport = false;
|
||||
}
|
||||
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f, // LOGICAL_W
|
||||
1000.0f, // LOGICAL_H
|
||||
logicalScale,
|
||||
if (coopActive && ctx.coopGame) {
|
||||
GameRenderer::renderCoopPlayingState(
|
||||
renderer,
|
||||
ctx.coopGame,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
paused,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH
|
||||
);
|
||||
} else {
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f, // LOGICAL_W
|
||||
1000.0f, // LOGICAL_H
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH,
|
||||
challengeClearFx,
|
||||
@ -272,7 +467,8 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
challengeClearDuration,
|
||||
countdown ? nullptr : ctx.challengeStoryText,
|
||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// Reset to screen
|
||||
SDL_SetRenderTarget(renderer, nullptr);
|
||||
@ -319,6 +515,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
SDL_SetRenderViewport(renderer, &oldVP);
|
||||
SDL_SetRenderScale(renderer, oldSX, oldSY);
|
||||
|
||||
// Net overlay (on top of blurred game, under pause/exit overlays)
|
||||
renderNetOverlay();
|
||||
|
||||
// Draw overlays
|
||||
if (exitPopup) {
|
||||
GameRenderer::renderExitPopup(
|
||||
@ -341,33 +540,57 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
||||
|
||||
} else {
|
||||
// Render normally directly to screen
|
||||
if (s_pendingTransport) {
|
||||
if (!coopActive && s_pendingTransport) {
|
||||
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||
s_pendingTransport = false;
|
||||
}
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH,
|
||||
challengeClearFx,
|
||||
challengeClearOrder,
|
||||
challengeClearElapsed,
|
||||
challengeClearDuration,
|
||||
countdown ? nullptr : ctx.challengeStoryText,
|
||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||
);
|
||||
|
||||
if (coopActive && ctx.coopGame) {
|
||||
GameRenderer::renderCoopPlayingState(
|
||||
renderer,
|
||||
ctx.coopGame,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
paused,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH
|
||||
);
|
||||
|
||||
// Net overlay (on top of coop HUD)
|
||||
renderNetOverlay();
|
||||
} else {
|
||||
GameRenderer::renderPlayingState(
|
||||
renderer,
|
||||
ctx.game,
|
||||
ctx.pixelFont,
|
||||
ctx.lineEffect,
|
||||
ctx.blocksTex,
|
||||
ctx.asteroidsTex,
|
||||
ctx.statisticsPanelTex,
|
||||
ctx.scorePanelTex,
|
||||
ctx.nextPanelTex,
|
||||
ctx.holdPanelTex,
|
||||
countdown,
|
||||
1200.0f,
|
||||
1000.0f,
|
||||
logicalScale,
|
||||
(float)winW,
|
||||
(float)winH,
|
||||
challengeClearFx,
|
||||
challengeClearOrder,
|
||||
challengeClearElapsed,
|
||||
challengeClearDuration,
|
||||
countdown ? nullptr : ctx.challengeStoryText,
|
||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,13 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
#include "../network/NetSession.h"
|
||||
|
||||
// Forward declarations for frequently used types
|
||||
class Game;
|
||||
class CoopGame;
|
||||
class ScoreManager;
|
||||
class Starfield;
|
||||
class Starfield3D;
|
||||
@ -24,6 +28,7 @@ class StateManager;
|
||||
struct StateContext {
|
||||
// Core subsystems (may be null if not available)
|
||||
Game* game = nullptr;
|
||||
CoopGame* coopGame = nullptr;
|
||||
ScoreManager* scores = nullptr;
|
||||
Starfield* starfield = nullptr;
|
||||
Starfield3D* starfield3D = nullptr;
|
||||
@ -77,12 +82,33 @@ struct StateContext {
|
||||
int* challengeStoryLevel = nullptr; // Cached level for the current story line
|
||||
float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade
|
||||
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
|
||||
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
|
||||
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()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
|
||||
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
|
||||
StateManager* stateManager = nullptr;
|
||||
// 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);
|
||||
}
|
||||
|
||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI) {
|
||||
BottomMenu menu{};
|
||||
|
||||
auto rects = computeMenuButtonRects(params);
|
||||
@ -22,12 +22,14 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
||||
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
||||
|
||||
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
||||
menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false };
|
||||
menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true };
|
||||
menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true };
|
||||
menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true };
|
||||
menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true };
|
||||
menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", true };
|
||||
// Always show a neutral "COOPERATE" label (remove per-mode suffixes)
|
||||
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
|
||||
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
|
||||
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
|
||||
menu.buttons[4] = Button{ BottomMenuItem::Options, rects[4], "OPTIONS", true };
|
||||
menu.buttons[5] = Button{ BottomMenuItem::Help, rects[5], "HELP", true };
|
||||
menu.buttons[6] = Button{ BottomMenuItem::About, rects[6], "ABOUT", true };
|
||||
menu.buttons[7] = Button{ BottomMenuItem::Exit, rects[7], "EXIT", true };
|
||||
|
||||
return menu;
|
||||
}
|
||||
@ -62,10 +64,15 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
||||
|
||||
if (!b.textOnly) {
|
||||
const bool isPlay = (i == 0);
|
||||
const bool isChallenge = (i == 1);
|
||||
const bool isCoop = (i == 1);
|
||||
const bool isChallenge = (i == 2);
|
||||
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
||||
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
||||
if (isChallenge) {
|
||||
if (isCoop) {
|
||||
// Cooperative mode gets a cyan/magenta accent to separate from Endless/Challenge
|
||||
bgCol = SDL_Color{ 22, 30, 40, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||
bdCol = SDL_Color{ 160, 210, 255, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||
} else if (isChallenge) {
|
||||
// Give Challenge a teal accent to distinguish from Play
|
||||
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||
@ -82,14 +89,14 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
||||
}
|
||||
}
|
||||
|
||||
// '+' separators between the bottom HUD buttons (indices 2..last)
|
||||
// '+' separators between the bottom HUD buttons (indices 3..last)
|
||||
{
|
||||
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
||||
|
||||
const int firstSmall = 2;
|
||||
const int firstSmall = 3;
|
||||
const int lastSmall = MENU_BTN_COUNT - 1;
|
||||
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
||||
for (int i = firstSmall; i < lastSmall; ++i) {
|
||||
|
||||
@ -15,12 +15,13 @@ namespace ui {
|
||||
|
||||
enum class BottomMenuItem : int {
|
||||
Play = 0,
|
||||
Challenge = 1,
|
||||
Level = 2,
|
||||
Options = 3,
|
||||
Help = 4,
|
||||
About = 5,
|
||||
Exit = 6,
|
||||
Cooperate = 1,
|
||||
Challenge = 2,
|
||||
Level = 3,
|
||||
Options = 4,
|
||||
Help = 5,
|
||||
About = 6,
|
||||
Exit = 7,
|
||||
};
|
||||
|
||||
struct Button {
|
||||
@ -34,11 +35,11 @@ struct BottomMenu {
|
||||
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.
|
||||
// hoveredIndex: -1..5
|
||||
// selectedIndex: 0..5 (keyboard selection)
|
||||
// hoveredIndex: -1..7
|
||||
// selectedIndex: 0..7 (keyboard selection)
|
||||
// alphaMul: 0..1 (overall group alpha)
|
||||
void renderBottomMenu(SDL_Renderer* renderer,
|
||||
FontAtlas* font,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
#include "ui/MenuLayout.h"
|
||||
#include "ui/UIConstants.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace ui {
|
||||
|
||||
@ -12,7 +13,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
||||
|
||||
// Cockpit HUD layout (matches main_screen art):
|
||||
// - Top row: PLAY and CHALLENGE (big buttons)
|
||||
// - Top row: PLAY / COOPERATE / CHALLENGE (big buttons)
|
||||
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
||||
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
||||
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
||||
@ -26,9 +27,10 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float smallSpacing = 26.0f;
|
||||
|
||||
// Scale down for narrow windows so nothing goes offscreen.
|
||||
const int smallCount = MENU_BTN_COUNT - 2;
|
||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||
float topRowTotal = playW * 2.0f + bigGap;
|
||||
const int bigCount = 3;
|
||||
const int smallCount = MENU_BTN_COUNT - bigCount;
|
||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(std::max(smallCount - 1, 0));
|
||||
float topRowTotal = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||
if (smallTotal > availableW || topRowTotal > availableW) {
|
||||
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
||||
smallW *= s;
|
||||
@ -48,11 +50,13 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
||||
|
||||
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
||||
// Top row big buttons
|
||||
float playLeft = centerX - (playW + bigGap * 0.5f);
|
||||
float challengeLeft = centerX + bigGap * 0.5f;
|
||||
rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH };
|
||||
rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH };
|
||||
// Top row big buttons (PLAY / COOPERATE / CHALLENGE)
|
||||
float bigRowW = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||
float leftBig = centerX - bigRowW * 0.5f;
|
||||
for (int i = 0; i < bigCount; ++i) {
|
||||
float x = leftBig + i * (playW + bigGap);
|
||||
rects[i] = SDL_FRect{ x, playCY - playH * 0.5f, playW, playH };
|
||||
}
|
||||
|
||||
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||
float left = centerX - rowW * 0.5f;
|
||||
@ -63,7 +67,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
||||
|
||||
for (int i = 0; i < smallCount; ++i) {
|
||||
float x = left + i * (smallW + smallSpacing);
|
||||
rects[i + 2] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||
rects[i + bigCount] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ struct MenuLayoutParams {
|
||||
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
|
||||
|
||||
// Hit test a point given in logical content-local coordinates against menu buttons
|
||||
// Returns index 0..4 or -1 if none
|
||||
// Returns index 0..(MENU_BTN_COUNT-1) or -1 if none
|
||||
int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
|
||||
|
||||
// Return settings button rect (logical coords)
|
||||
|
||||
@ -83,6 +83,6 @@ void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicE
|
||||
bool sfxOn = true;
|
||||
font.draw(renderer, popupX + 140, popupY + 100, sfxOn ? "ON" : "OFF", 1.5f, sfxOn ? SDL_Color{0,255,0,255} : SDL_Color{255,0,0,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, SDL_Color{200,200,220,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255});
|
||||
font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, SDL_Color{200,200,220,255});
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
static constexpr int MENU_BTN_COUNT = 7;
|
||||
static constexpr int MENU_BTN_COUNT = 8;
|
||||
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
||||
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
||||
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
||||
|
||||
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",
|
||||
"features": ["jpeg", "png", "webp"]
|
||||
},
|
||||
"enet",
|
||||
"catch2",
|
||||
"gtest",
|
||||
"cpr",
|
||||
"nlohmann-json"
|
||||
"nlohmann-json",
|
||||
"ffmpeg"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user