Compare commits

...

35 Commits

Author SHA1 Message Date
60d6a9e740 Merge branch 'release/v0.1.0'
Some checks failed
Build and Package Spacetris / build-windows (push) Has been cancelled
Build and Package Spacetris / build-linux (push) Has been cancelled
2025-12-25 10:03:42 +01:00
e1921858ed Merge branch 'feature/NetworkMultiplayerCooperate' into develop 2025-12-25 09:38:19 +01:00
14cb96345c Added intro video 2025-12-25 09:38:06 +01:00
d28feb3276 minor fixes 2025-12-23 20:24:50 +01:00
a7a3ae9055 added basic network play 2025-12-23 19:03:33 +01:00
5ec4bf926b added rules 2025-12-23 17:16:12 +01:00
0e04617968 Merge branch 'feature/CooperateAiPlayer' into develop 2025-12-23 16:50:51 +01:00
b450e2af21 fixed menu 2025-12-23 14:49:55 +01:00
a65756f298 fixed menu 2025-12-23 14:12:37 +01:00
dac312ef2b updated main menu for cooperate mode 2025-12-23 12:21:33 +01:00
953d6af701 fixed cooperate play 2025-12-22 21:26:56 +01:00
c14e305a4a Merge branch 'feature/CooperativeMode' into develop 2025-12-22 18:50:05 +01:00
fb036dede5 removed 2025-12-22 18:49:54 +01:00
3c9dc0ff65 update visually 2025-12-22 18:49:06 +01:00
d3ca238a51 updated sync line 2025-12-22 17:18:29 +01:00
a729dc089e sync line added in cooperate mode 2025-12-22 17:13:35 +01:00
18463774e9 fixed for cooperate mode 2025-12-22 13:48:54 +01:00
694243ac89 fixed highscore in main menu 2025-12-22 13:09:36 +01:00
60ddc9ddd3 fixed name entry 2025-12-21 21:37:04 +01:00
70946fc720 fixed highscores 2025-12-21 21:33:31 +01:00
fb82ac06d0 fixed highscores 2025-12-21 21:17:58 +01:00
494f906435 supabase integration instead firebase 2025-12-21 20:50:44 +01:00
50c869536d highscore fixes 2025-12-21 19:45:20 +01:00
0b99911f5d fixed i block 2025-12-21 18:43:40 +01:00
33d5eedec8 fixed I block in coop mode 2025-12-21 18:11:21 +01:00
744268fedd added pause option coop gameplay 2025-12-21 17:59:21 +01:00
06aa63f548 fixed score display 2025-12-21 17:52:07 +01:00
a9943ce8bf when clearing lines play voices 2025-12-21 17:26:53 +01:00
b46af7ab1d removed s shortcut for sound fx toggle 2025-12-21 17:22:30 +01:00
ab22d4c34f hard drop shake effect added 2025-12-21 17:04:46 +01:00
e2d6ea64a4 added hard drop 2025-12-21 16:37:20 +01:00
322744c296 fixed first row 2025-12-21 16:31:23 +01:00
cf3e897752 added clear line effect 2025-12-21 16:25:09 +01:00
4efb60bb5b added ghost block 2025-12-21 15:59:46 +01:00
afd7fdf18d basic gameplay for cooperative 2025-12-21 15:33:37 +01:00
52 changed files with 6699 additions and 461 deletions

168
.copilot-rules.md Normal file
View File

@ -0,0 +1,168 @@
# Copilot Rules — Spacetris (SDL3 / C++20)
These rules define **non-negotiable constraints** for all AI-assisted changes.
They exist to preserve determinism, performance, and architecture.
If these rules conflict with `.github/copilot-instructions.md`,
**follow `.github/copilot-instructions.md`.**
---
## Project Constraints (Non-Negotiable)
- Language: **C++20**
- Runtime: **SDL3** + **SDL3_ttf**
- Build system: **CMake**
- Dependencies via **vcpkg**
- Assets must use **relative paths only**
- Deterministic gameplay logic is mandatory
Do not rewrite or refactor working systems unless explicitly requested.
---
## Repo Layout & Responsibilities
- Core gameplay loop/state: `src/Game.*`
- Entry point: `src/main.cpp`
- Text/TTF: `src/Font.*`
- Audio: `src/Audio.*`, `src/SoundEffect.*`
- Effects: `src/LineEffect.*`, `src/Starfield*.cpp`
- High scores: `src/Scores.*`
- Packaging: `build-production.ps1`
When adding a module:
- Place it under `src/` (or an established subfolder)
- Register it in `CMakeLists.txt`
- Avoid circular includes
- Keep headers minimal
---
## Build & Verification
Prefer existing scripts:
- Debug: `cmake --build build-msvc --config Debug`
- Release:
- Configure: `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release`
- Build: `cmake --build build-release --config Release`
- Packaging (Windows): `./build-production.ps1`
Before finalizing changes:
- Debug build must succeed
- Packaging must succeed if assets or DLLs are touched
Do not introduce new build steps unless required.
---
## Coding & Architecture Rules
- Match local file style (naming, braces, spacing)
- Avoid large refactors
- Prefer small, testable helpers
- Avoid floating-point math in core gameplay state
- Game logic must be deterministic
- Rendering code must not mutate game state
---
## Rendering & Performance Rules
- Do not allocate memory per frame
- Do not load assets during rendering
- No blocking calls in render loop
- Visual effects must be time-based (`deltaTime`)
- Rendering must not contain gameplay logic
---
## Threading Rules
- SDL main thread:
- Rendering
- Input
- Game simulation
- Networking must be **non-blocking** from the SDL main loop
- Either run networking on a separate thread, or poll ENet frequently with a 0 timeout
- Never wait/spin for remote inputs on the render thread
- Cross-thread communication via queues or buffers only
---
## Assets, Fonts, and Paths
- Runtime expects adjacent `assets/` directory
- `FreeSans.ttf` must remain at repo root
- New assets:
- Go under `assets/`
- Must be included in `build-production.ps1`
Never hardcode machine-specific paths.
---
## AI Partner (COOPERATE Mode)
- AI is **supportive**, not competitive
- AI must respect sync timing and shared grid logic
- AI must not “cheat” or see hidden future pieces
- AI behavior must be deterministic per seed/difficulty
---
## Networking (COOPERATE Network Mode)
Follow `docs/ai/cooperate_network.md`.
If `network_cooperate_multiplayer.md` exists, keep it consistent with the canonical doc.
Mandatory model:
- **Input lockstep**
- Transmit inputs only (no board state replication)
Determinism requirements:
- Fixed tick (e.g. 60 Hz)
- Shared RNG seed
- Deterministic gravity, rotation, locking, scoring
Technology:
- Use **ENet**
- Do NOT use SDL_net or TCP-only networking
Architecture:
- Networking must be isolated (e.g. `src/network/NetSession.*`)
- Game logic must not care if partner is local, AI, or network
Robustness:
- Input delay buffer (46 ticks)
- Periodic desync hashing
- Graceful disconnect handling
Do NOT implement:
- Rollback
- Full state sync
- Server-authoritative sim
- Matchmaking SDKs
- Versus mechanics
---
## Agent Behavior Rules (IMPORTANT)
- Always read relevant markdown specs **before coding**
- Treat markdown specs as authoritative
- Do not invent APIs
- Do not assume external libraries exist
- Generate code **file by file**, not everything at once
- Ask before changing architecture or ownership boundaries
---
## When to Ask Before Proceeding
Ask the maintainer if unclear:
- UX or menu flow decisions
- Adding dependencies
- Refactors vs local patches
- Platform-specific behavior

View File

@ -28,11 +28,14 @@ find_package(SDL3_ttf CONFIG REQUIRED)
find_package(SDL3_image CONFIG REQUIRED) find_package(SDL3_image CONFIG REQUIRED)
find_package(cpr CONFIG REQUIRED) find_package(cpr CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED)
find_package(unofficial-enet CONFIG REQUIRED)
set(TETRIS_SOURCES set(TETRIS_SOURCES
src/main.cpp src/main.cpp
src/app/TetrisApp.cpp src/app/TetrisApp.cpp
src/gameplay/core/Game.cpp src/gameplay/core/Game.cpp
src/gameplay/coop/CoopGame.cpp
src/gameplay/coop/CoopAIController.cpp
src/core/GravityManager.cpp src/core/GravityManager.cpp
src/core/state/StateManager.cpp src/core/state/StateManager.cpp
# New core architecture classes # New core architecture classes
@ -43,16 +46,20 @@ set(TETRIS_SOURCES
src/core/Settings.cpp src/core/Settings.cpp
src/graphics/renderers/RenderManager.cpp src/graphics/renderers/RenderManager.cpp
src/persistence/Scores.cpp src/persistence/Scores.cpp
src/network/supabase_client.cpp
src/network/NetSession.cpp
src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield.cpp
src/graphics/effects/Starfield3D.cpp src/graphics/effects/Starfield3D.cpp
src/graphics/effects/SpaceWarp.cpp src/graphics/effects/SpaceWarp.cpp
src/graphics/ui/Font.cpp src/graphics/ui/Font.cpp
src/graphics/ui/HelpOverlay.cpp src/graphics/ui/HelpOverlay.cpp
src/graphics/renderers/GameRenderer.cpp src/graphics/renderers/GameRenderer.cpp
src/graphics/renderers/SyncLineRenderer.cpp
src/graphics/renderers/UIRenderer.cpp src/graphics/renderers/UIRenderer.cpp
src/audio/Audio.cpp src/audio/Audio.cpp
src/gameplay/effects/LineEffect.cpp src/gameplay/effects/LineEffect.cpp
src/audio/SoundEffect.cpp src/audio/SoundEffect.cpp
src/video/VideoPlayer.cpp
src/ui/MenuLayout.cpp src/ui/MenuLayout.cpp
src/ui/BottomMenu.cpp src/ui/BottomMenu.cpp
src/app/BackgroundManager.cpp src/app/BackgroundManager.cpp
@ -62,6 +69,7 @@ set(TETRIS_SOURCES
src/states/LoadingManager.cpp src/states/LoadingManager.cpp
# State implementations (new) # State implementations (new)
src/states/LoadingState.cpp src/states/LoadingState.cpp
src/states/VideoState.cpp
src/states/MenuState.cpp src/states/MenuState.cpp
src/states/OptionsState.cpp src/states/OptionsState.cpp
src/states/LevelSelectorState.cpp src/states/LevelSelectorState.cpp
@ -156,10 +164,17 @@ if(APPLE)
endif() endif()
endif() endif()
target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json) target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet)
find_package(FFMPEG REQUIRED)
if(FFMPEG_FOUND)
target_include_directories(spacetris PRIVATE ${FFMPEG_INCLUDE_DIRS})
target_link_directories(spacetris PRIVATE ${FFMPEG_LIBRARY_DIRS})
target_link_libraries(spacetris PRIVATE ${FFMPEG_LIBRARIES})
endif()
if (WIN32) if (WIN32)
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid) target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
endif() endif()
if(APPLE) if(APPLE)
# Needed for MP3 decoding via AudioToolbox on macOS # Needed for MP3 decoding via AudioToolbox on macOS
@ -190,6 +205,7 @@ endif()
target_include_directories(spacetris PRIVATE target_include_directories(spacetris PRIVATE
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/src/audio ${CMAKE_SOURCE_DIR}/src/audio
${CMAKE_SOURCE_DIR}/src/video
${CMAKE_SOURCE_DIR}/src/gameplay ${CMAKE_SOURCE_DIR}/src/gameplay
${CMAKE_SOURCE_DIR}/src/graphics ${CMAKE_SOURCE_DIR}/src/graphics
${CMAKE_SOURCE_DIR}/src/persistence ${CMAKE_SOURCE_DIR}/src/persistence

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

View File

@ -0,0 +1,271 @@
# Spacetris — COOPERATE Mode
## Network Multiplayer (2 PLAYER NETWORK)
### VS Code Copilot AI Agent Prompt
You are integrating **online cooperative multiplayer** into an existing **C++ / SDL3 game** called **Spacetris**.
This feature extends the existing **COOPERATE mode** to support:
- Local 2 players
- Human + AI
- **Human + Human over network (NEW)**
The networking solution must be **deterministic, lightweight, and stable**.
---
## 1. High-Level Goal
Add **COOPERATE 2 PLAYER (NETWORK)** mode where:
- Two players play together over the internet
- Each player controls one half of the shared grid
- A line clears only when both halves are filled
- Gameplay remains identical to local COOPERATE mode
---
## 2. Technology Constraints
- Language: **C++**
- Engine: **SDL3**
- Networking: **ENet (UDP with reliability)**
- No engine rewrite
- No authoritative server logic required (co-op only)
SDL3 is used ONLY for:
- Rendering
- Input
- Timing
Networking is a **separate layer**.
---
## 3. Network Model (MANDATORY)
### Use **Input Lockstep Networking**
#### Core idea:
- Both clients run the same deterministic simulation
- Only **player inputs** are sent over the network
- No board state is transmitted
- Both simulations must remain identical
This model is ideal for Tetris-like games.
---
## 4. Determinism Requirements (CRITICAL)
To ensure lockstep works:
- Fixed simulation tick (e.g. 60 Hz)
- Identical RNG seed for both clients
- Deterministic piece generation (bag system)
- No floating-point math in core gameplay
- Same gravity, rotation, lock-delay logic
- Identical line clear and scoring rules
Before networking:
- Input recording + replay must produce identical results
---
## 5. Network Topology
### Host / Client Model (Initial Implementation)
- One player hosts the game
- One player joins
- Host is authoritative for:
- RNG seed
- start tick
- game settings
This is sufficient and fair for cooperative gameplay.
---
## 6. Network Library
Use **ENet** for:
- Reliable, ordered UDP packets
- Low latency
- Simple integration with C++
Do NOT use:
- SDL_net
- TCP-only networking
- High-level matchmaking SDKs
---
## 7. Network Packet Design
### Input Packet (Minimal)
```cpp
struct InputPacket {
uint32_t tick;
uint8_t buttons; // bitmask
};
````
Button bitmask example:
* bit 0 move left
* bit 1 move right
* bit 2 rotate
* bit 3 soft drop
* bit 4 hard drop
* bit 5 hold
Packets must be:
* Reliable
* Ordered
* Small
---
## 8. Tick & Latency Handling
### Input Delay Buffer (RECOMMENDED)
* Add fixed delay: **46 ticks**
* Simulate tick `T` using inputs for `T + delay`
* Prevents stalls due to latency spikes
Strict lockstep without buffering is NOT recommended.
---
## 9. Desync Detection (IMPORTANT)
Every N ticks (e.g. once per second):
* Compute a hash of:
* Both grid halves
* Active pieces
* RNG index
* Score / lines / level
* Exchange hashes
* If mismatch:
* Log desync
* Stop game or mark session invalid
This is required for debugging and stability.
---
## 10. Network Session Architecture
Create a dedicated networking module:
```
/network
NetSession.h
NetSession.cpp
```
Responsibilities:
* ENet host/client setup
* Input packet send/receive
* Tick synchronization
* Latency buffering
* Disconnect handling
SDL main loop must NOT block on networking.
---
## 11. Integration with Existing COOPERATE Logic
* COOPERATE grid logic stays unchanged
* SyncLineRenderer remains unchanged
* Scoring logic remains unchanged
* Network layer only injects **remote inputs**
Game logic should not know whether partner is:
* Local human
* AI
* Network player
---
## 12. UI Integration (Menu Changes)
In COOPERATE selection screen, add a new button:
```
[ LOCAL CO-OP ] [ AI PARTNER ] [ 2 PLAYER (NETWORK) ]
```
### On selecting 2 PLAYER (NETWORK):
* Show:
* Host Game
* Join Game
* Display join code or IP
* Confirm connection before starting
---
## 13. Start Game Flow (Network)
1. Host creates session
2. Client connects
3. Host sends:
* RNG seed
* start tick
* game settings
4. Both wait until agreed start tick
5. Simulation begins simultaneously
---
## 14. Disconnect & Error Handling
* If connection drops:
* Pause game
* Show “Reconnecting…”
* After timeout:
* End match or switch to AI (optional)
* Never crash
* Never corrupt game state
---
## 15. What NOT to Implement
* ❌ Full state synchronization
* ❌ Prediction / rollback
* ❌ Server-authoritative gameplay
* ❌ Complex matchmaking
* ❌ Versus mechanics
This is cooperative, not competitive.
---
## 16. Acceptance Criteria
* Two players can complete COOPERATE mode over network
* Gameplay matches local COOPERATE exactly
* No noticeable input lag under normal latency
* Desync detection works
* Offline / disconnect handled gracefully
* SDL3 render loop remains smooth
---
## 17. Summary for Copilot
Integrate networked cooperative multiplayer into Spacetris using SDL3 + C++ with ENet. Implement input lockstep networking with deterministic simulation, fixed tick rate, input buffering, and desync detection. Add a new COOPERATE menu option “2 PLAYER (NETWORK)” that allows host/join flow. Networking must be modular, non-blocking, and transparent to existing gameplay logic.

0
scripts/check_braces.ps1 Normal file
View File

View File

View File

View File

@ -144,4 +144,7 @@ void draw(SDL_Renderer* renderer, SDL_Texture*) {
double getLogoAnimCounter() { return logoAnimCounter; } double getLogoAnimCounter() { return logoAnimCounter; }
int getHoveredButton() { return hoveredButton; } int getHoveredButton() { return hoveredButton; }
void spawn(float x, float y) {
fireworks.emplace_back(x, y);
}
} // namespace AppFireworks } // namespace AppFireworks

View File

@ -6,4 +6,5 @@ namespace AppFireworks {
void update(double frameMs); void update(double frameMs);
double getLogoAnimCounter(); double getLogoAnimCounter();
int getHoveredButton(); int getHoveredButton();
void spawn(float x, float y);
} }

View File

@ -37,6 +37,8 @@
#include "core/state/StateManager.h" #include "core/state/StateManager.h"
#include "gameplay/core/Game.h" #include "gameplay/core/Game.h"
#include "gameplay/coop/CoopGame.h"
#include "gameplay/coop/CoopAIController.h"
#include "gameplay/effects/LineEffect.h" #include "gameplay/effects/LineEffect.h"
#include "graphics/effects/SpaceWarp.h" #include "graphics/effects/SpaceWarp.h"
@ -47,6 +49,9 @@
#include "graphics/ui/Font.h" #include "graphics/ui/Font.h"
#include "graphics/ui/HelpOverlay.h" #include "graphics/ui/HelpOverlay.h"
#include "network/CoopNetButtons.h"
#include "network/NetSession.h"
#include "persistence/Scores.h" #include "persistence/Scores.h"
#include "states/LevelSelectorState.h" #include "states/LevelSelectorState.h"
@ -55,6 +60,7 @@
#include "states/MenuState.h" #include "states/MenuState.h"
#include "states/OptionsState.h" #include "states/OptionsState.h"
#include "states/PlayingState.h" #include "states/PlayingState.h"
#include "states/VideoState.h"
#include "states/State.h" #include "states/State.h"
#include "ui/BottomMenu.h" #include "ui/BottomMenu.h"
@ -171,6 +177,8 @@ struct TetrisApp::Impl {
int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
bool isNewHighScore = false; bool isNewHighScore = false;
std::string playerName; std::string playerName;
std::string player2Name;
int highScoreEntryIndex = 0; // 0 = entering player1, 1 = entering player2
bool helpOverlayPausedGame = false; bool helpOverlayPausedGame = false;
SDL_Window* window = nullptr; SDL_Window* window = nullptr;
@ -228,6 +236,7 @@ struct TetrisApp::Impl {
std::atomic<size_t> loadingStep{0}; std::atomic<size_t> loadingStep{0};
std::unique_ptr<Game> game; std::unique_ptr<Game> game;
std::unique_ptr<CoopGame> coopGame;
std::vector<std::string> singleSounds; std::vector<std::string> singleSounds;
std::vector<std::string> doubleSounds; std::vector<std::string> doubleSounds;
std::vector<std::string> tripleSounds; std::vector<std::string> tripleSounds;
@ -235,6 +244,11 @@ struct TetrisApp::Impl {
bool suppressLineVoiceForLevelUp = false; bool suppressLineVoiceForLevelUp = false;
bool skipNextLevelUpJingle = false; bool skipNextLevelUpJingle = false;
// COOPERATE option: when true, right player is AI-controlled.
bool coopVsAI = false;
CoopAIController coopAI;
AppState state = AppState::Loading; AppState state = AppState::Loading;
double loadingProgress = 0.0; double loadingProgress = 0.0;
Uint64 loadStart = 0; Uint64 loadStart = 0;
@ -242,7 +256,19 @@ struct TetrisApp::Impl {
bool isFullscreen = false; bool isFullscreen = false;
bool leftHeld = false; bool leftHeld = false;
bool rightHeld = false; bool rightHeld = false;
bool p1LeftHeld = false;
bool p1RightHeld = false;
bool p2LeftHeld = false;
bool p2RightHeld = false;
double moveTimerMs = 0.0; double moveTimerMs = 0.0;
double p1MoveTimerMs = 0.0;
double p2MoveTimerMs = 0.0;
// Network coop fixed-tick state (used only when ctx.coopNetEnabled is true)
double coopNetAccMs = 0.0;
uint32_t coopNetCachedTick = 0xFFFFFFFFu;
uint8_t coopNetCachedButtons = 0;
uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu;
double DAS = 170.0; double DAS = 170.0;
double ARR = 40.0; double ARR = 40.0;
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
@ -285,11 +311,21 @@ struct TetrisApp::Impl {
std::unique_ptr<StateManager> stateMgr; std::unique_ptr<StateManager> stateMgr;
StateContext ctx{}; StateContext ctx{};
std::unique_ptr<LoadingState> loadingState; std::unique_ptr<LoadingState> loadingState;
std::unique_ptr<VideoState> videoState;
std::unique_ptr<MenuState> menuState; std::unique_ptr<MenuState> menuState;
std::unique_ptr<OptionsState> optionsState; std::unique_ptr<OptionsState> optionsState;
std::unique_ptr<LevelSelectorState> levelSelectorState; std::unique_ptr<LevelSelectorState> levelSelectorState;
std::unique_ptr<PlayingState> playingState; std::unique_ptr<PlayingState> playingState;
// Startup fade-in overlay (used after intro video).
bool startupFadeActive = false;
float startupFadeAlpha = 0.0f; // 0..1 black overlay strength
double startupFadeClockMs = 0.0;
static constexpr double STARTUP_FADE_IN_MS = 650.0;
// Intro video path.
std::string introVideoPath = "assets/videos/spacetris_intro.mp4";
int init(); int init();
void runLoop(); void runLoop();
void shutdown(); void shutdown();
@ -421,6 +457,8 @@ int TetrisApp::Impl::init()
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER); game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
game->reset(startLevelSelection); game->reset(startLevelSelection);
coopGame = std::make_unique<CoopGame>(startLevelSelection);
// Define voice line banks for gameplay callbacks // Define voice line banks for gameplay callbacks
singleSounds = {"well_played", "smooth_clear", "great_move"}; singleSounds = {"well_played", "smooth_clear", "great_move"};
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"}; doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
@ -458,6 +496,20 @@ int TetrisApp::Impl::init()
suppressLineVoiceForLevelUp = false; suppressLineVoiceForLevelUp = false;
}); });
// Keep co-op line-clear SFX behavior identical to classic.
coopGame->setSoundCallback([this, playVoiceCue](int linesCleared) {
if (linesCleared <= 0) {
return;
}
SoundEffectManager::instance().playSound("clear_line", 1.0f);
if (!suppressLineVoiceForLevelUp) {
playVoiceCue(linesCleared);
}
suppressLineVoiceForLevelUp = false;
});
game->setLevelUpCallback([this](int /*newLevel*/) { game->setLevelUpCallback([this](int /*newLevel*/) {
if (skipNextLevelUpJingle) { if (skipNextLevelUpJingle) {
skipNextLevelUpJingle = false; skipNextLevelUpJingle = false;
@ -468,6 +520,17 @@ int TetrisApp::Impl::init()
suppressLineVoiceForLevelUp = true; suppressLineVoiceForLevelUp = true;
}); });
// Mirror single-player level-up audio/visual behavior for Coop sessions
coopGame->setLevelUpCallback([this](int /*newLevel*/) {
if (skipNextLevelUpJingle) {
skipNextLevelUpJingle = false;
} else {
SoundEffectManager::instance().playSound("new_level", 1.0f);
SoundEffectManager::instance().playSound("lets_go", 1.0f);
}
suppressLineVoiceForLevelUp = true;
});
game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) { game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) {
SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f); SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f);
}); });
@ -479,7 +542,10 @@ int TetrisApp::Impl::init()
isFullscreen = Settings::instance().isFullscreen(); isFullscreen = Settings::instance().isFullscreen();
leftHeld = false; leftHeld = false;
rightHeld = false; rightHeld = false;
p1LeftHeld = p1RightHeld = p2LeftHeld = p2RightHeld = false;
moveTimerMs = 0; moveTimerMs = 0;
p1MoveTimerMs = 0.0;
p2MoveTimerMs = 0.0;
DAS = 170.0; DAS = 170.0;
ARR = 40.0; ARR = 40.0;
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H}; logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
@ -506,6 +572,7 @@ int TetrisApp::Impl::init()
ctx = StateContext{}; ctx = StateContext{};
ctx.stateManager = stateMgr.get(); ctx.stateManager = stateMgr.get();
ctx.game = game.get(); ctx.game = game.get();
ctx.coopGame = coopGame.get();
ctx.scores = nullptr; ctx.scores = nullptr;
ctx.starfield = &starfield; ctx.starfield = &starfield;
ctx.starfield3D = &starfield3D; ctx.starfield3D = &starfield3D;
@ -526,6 +593,7 @@ int TetrisApp::Impl::init()
ctx.mainScreenW = mainScreenW; ctx.mainScreenW = mainScreenW;
ctx.mainScreenH = mainScreenH; ctx.mainScreenH = mainScreenH;
ctx.musicEnabled = &musicEnabled; ctx.musicEnabled = &musicEnabled;
ctx.coopVsAI = &coopVsAI;
ctx.startLevelSelection = &startLevelSelection; ctx.startLevelSelection = &startLevelSelection;
ctx.hoveredButton = &hoveredButton; ctx.hoveredButton = &hoveredButton;
ctx.showSettingsPopup = &showSettingsPopup; ctx.showSettingsPopup = &showSettingsPopup;
@ -587,10 +655,17 @@ int TetrisApp::Impl::init()
return; return;
} }
if (state != AppState::Menu) { if (state != AppState::Menu) {
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
coopAI.reset();
}
state = AppState::Playing; state = AppState::Playing;
ctx.stateManager->setState(state); ctx.stateManager->setState(state);
return; return;
} }
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
coopAI.reset();
}
beginStateFade(AppState::Playing, true); beginStateFade(AppState::Playing, true);
}; };
ctx.startPlayTransition = startMenuPlayTransition; ctx.startPlayTransition = startMenuPlayTransition;
@ -607,7 +682,11 @@ int TetrisApp::Impl::init()
}; };
ctx.requestFadeTransition = requestStateFade; ctx.requestFadeTransition = requestStateFade;
ctx.startupFadeActive = &startupFadeActive;
ctx.startupFadeAlpha = &startupFadeAlpha;
loadingState = std::make_unique<LoadingState>(ctx); loadingState = std::make_unique<LoadingState>(ctx);
videoState = std::make_unique<VideoState>(ctx);
menuState = std::make_unique<MenuState>(ctx); menuState = std::make_unique<MenuState>(ctx);
optionsState = std::make_unique<OptionsState>(ctx); optionsState = std::make_unique<OptionsState>(ctx);
levelSelectorState = std::make_unique<LevelSelectorState>(ctx); levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
@ -617,6 +696,20 @@ int TetrisApp::Impl::init()
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); }); stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
stateMgr->registerHandler(AppState::Video, [this](const SDL_Event& e){ if (videoState) videoState->handleEvent(e); });
stateMgr->registerOnEnter(AppState::Video, [this]() {
if (!videoState) return;
const bool ok = videoState->begin(renderer, introVideoPath);
if (!ok) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Intro video unavailable; skipping to Menu");
state = AppState::Menu;
stateMgr->setState(state);
return;
}
videoState->onEnter();
});
stateMgr->registerOnExit(AppState::Video, [this](){ if (videoState) videoState->onExit(); });
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); }); stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
@ -761,13 +854,14 @@ void TetrisApp::Impl::runLoop()
Settings::instance().setMusicEnabled(true); Settings::instance().setMusicEnabled(true);
} }
} }
if (e.key.scancode == SDL_SCANCODE_S) // K: Toggle sound effects (S is reserved for co-op movement)
if (e.key.scancode == SDL_SCANCODE_K)
{ {
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled()); SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
} }
const bool helpToggleKey = const bool helpToggleKey =
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu); (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu);
if (helpToggleKey) if (helpToggleKey)
{ {
showHelpOverlay = !showHelpOverlay; showHelpOverlay = !showHelpOverlay;
@ -837,27 +931,81 @@ void TetrisApp::Impl::runLoop()
} }
if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) { if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
if (playerName.length() < 12) { // Support single-player and coop two-name entry
playerName += e.text.text; if (game && game->getMode() == GameMode::Cooperate && coopGame) {
if (highScoreEntryIndex == 0) {
if (playerName.length() < 12) playerName += e.text.text;
} else {
if (player2Name.length() < 12) player2Name += e.text.text;
}
} else {
if (playerName.length() < 12) playerName += e.text.text;
} }
} }
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (isNewHighScore) { if (isNewHighScore) {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) { if (game && game->getMode() == GameMode::Cooperate && coopGame) {
playerName.pop_back(); if (coopVsAI) {
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { // One-name entry flow (CPU is LEFT, human enters RIGHT name)
if (playerName.empty()) playerName = "PLAYER"; if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
ensureScoresLoaded(); if (!player2Name.empty()) player2Name.pop_back();
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName); } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
Settings::instance().setPlayerName(playerName); if (player2Name.empty()) player2Name = "P2";
isNewHighScore = false; std::string combined = std::string("CPU") + " & " + player2Name;
SDL_StopTextInput(window); int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
int combinedScore = leftScore + rightScore;
ensureScoresLoaded();
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
Settings::instance().setPlayerName(player2Name);
isNewHighScore = false;
SDL_StopTextInput(window);
}
} else {
// Two-name entry flow
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (highScoreEntryIndex == 0) {
if (playerName.empty()) playerName = "P1";
highScoreEntryIndex = 1; // move to second name
} else {
if (player2Name.empty()) player2Name = "P2";
// Submit combined name
std::string combined = playerName + " & " + player2Name;
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
int combinedScore = leftScore + rightScore;
ensureScoresLoaded();
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
Settings::instance().setPlayerName(playerName);
isNewHighScore = false;
SDL_StopTextInput(window);
}
}
}
} else {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
playerName.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (playerName.empty()) playerName = "PLAYER";
ensureScoresLoaded();
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName, gt);
Settings::instance().setPlayerName(playerName);
isNewHighScore = false;
SDL_StopTextInput(window);
}
} }
} else { } else {
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) { if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
if (game->getMode() == GameMode::Challenge) { if (game->getMode() == GameMode::Challenge) {
game->startChallengeRun(1); game->startChallengeRun(1);
} else if (game->getMode() == GameMode::Cooperate) {
game->setMode(GameMode::Cooperate);
game->reset(startLevelSelection);
} else { } else {
game->setMode(GameMode::Endless); game->setMode(GameMode::Endless);
game->reset(startLevelSelection); game->reset(startLevelSelection);
@ -893,6 +1041,11 @@ void TetrisApp::Impl::runLoop()
if (game) game->setMode(GameMode::Endless); if (game) game->setMode(GameMode::Endless);
startMenuPlayTransition(); startMenuPlayTransition();
break; break;
case ui::BottomMenuItem::Cooperate:
if (menuState) {
menuState->showCoopSetupPanel(true);
}
break;
case ui::BottomMenuItem::Challenge: case ui::BottomMenuItem::Challenge:
if (game) { if (game) {
game->setMode(GameMode::Challenge); game->setMode(GameMode::Challenge);
@ -1034,12 +1187,31 @@ void TetrisApp::Impl::runLoop()
} }
} }
// State transitions can be triggered from render/update (e.g. menu network handshake).
// Keep our cached `state` in sync every frame, not only when events occur.
state = stateMgr->getState();
Uint64 now = SDL_GetPerformanceCounter(); Uint64 now = SDL_GetPerformanceCounter();
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
lastMs = now; lastMs = now;
if (frameMs > 100.0) frameMs = 100.0; if (frameMs > 100.0) frameMs = 100.0;
gameplayBackgroundClockMs += frameMs; gameplayBackgroundClockMs += frameMs;
if (startupFadeActive) {
if (startupFadeClockMs <= 0.0) {
startupFadeClockMs = STARTUP_FADE_IN_MS;
startupFadeAlpha = 1.0f;
}
startupFadeClockMs -= frameMs;
if (startupFadeClockMs <= 0.0) {
startupFadeClockMs = 0.0;
startupFadeAlpha = 0.0f;
startupFadeActive = false;
} else {
startupFadeAlpha = float(std::clamp(startupFadeClockMs / STARTUP_FADE_IN_MS, 0.0, 1.0));
}
}
auto clearChallengeStory = [this]() { auto clearChallengeStory = [this]() {
challengeStoryText.clear(); challengeStoryText.clear();
challengeStoryLevel = 0; challengeStoryLevel = 0;
@ -1153,29 +1325,365 @@ void TetrisApp::Impl::runLoop()
if (state == AppState::Playing) if (state == AppState::Playing)
{ {
if (!game->isPaused()) { const bool coopActive = game && game->getMode() == GameMode::Cooperate && coopGame;
game->tickGravity(frameMs);
game->updateElapsedTime();
if (lineEffect.isActive()) { if (coopActive) {
if (lineEffect.update(frameMs / 1000.0f)) { // Coop DAS/ARR handling (per-side)
game->clearCompletedLines(); const bool* ks = SDL_GetKeyboardState(nullptr);
auto handleSide = [&](CoopGame::PlayerSide side,
bool leftHeldPrev,
bool rightHeldPrev,
double& timer,
SDL_Scancode leftKey,
SDL_Scancode rightKey,
SDL_Scancode downKey) {
bool left = ks[leftKey];
bool right = ks[rightKey];
bool down = ks[downKey];
coopGame->setSoftDropping(side, down);
int moveDir = 0;
if (left && !right) moveDir = -1;
else if (right && !left) moveDir = +1;
if (moveDir != 0) {
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
coopGame->move(side, moveDir);
timer = DAS;
} else {
timer -= frameMs;
if (timer <= 0) {
coopGame->move(side, moveDir);
timer += ARR;
}
}
} else {
timer = 0.0;
}
};
if (game->isPaused()) {
// While paused, suppress all continuous input changes so pieces don't drift.
if (ctx.coopNetEnabled && ctx.coopNetSession) {
ctx.coopNetSession->poll(0);
ctx.coopNetStalled = false;
}
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
p1MoveTimerMs = 0.0;
p2MoveTimerMs = 0.0;
p1LeftHeld = false;
p1RightHeld = false;
p2LeftHeld = false;
p2RightHeld = false;
} else {
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
// If we just entered network co-op, reset per-session fixed-tick bookkeeping.
if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) {
coopNetAccMs = 0.0;
coopNetCachedTick = 0xFFFFFFFFu;
coopNetCachedButtons = 0;
coopNetLastHashSentTick = 0xFFFFFFFFu;
ctx.coopNetStalled = false;
}
// Define canonical key mappings for left and right players
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
const SDL_Scancode leftDownKey = SDL_SCANCODE_S;
const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT;
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
if (coopNetActive) {
// Network co-op: fixed tick lockstep.
// Use a fixed dt so both peers simulate identically.
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
ctx.coopNetSession->poll(0);
// If the connection drops during gameplay, abort back to menu.
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
: std::string("NET DISCONNECTED");
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
ctx.coopNetUiStatusText = reason;
ctx.coopNetUiStatusRemainingMs = 6000.0;
ctx.coopNetEnabled = false;
ctx.coopNetStalled = false;
ctx.coopNetDesyncDetected = false;
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
// Ensure we don't remain paused due to a previous net stall/desync.
if (game) {
game->setPaused(false);
}
state = AppState::Menu;
stateMgr->setState(state);
continue;
}
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
auto buildLocalButtons = [&]() -> uint8_t {
uint8_t b = 0;
if (ctx.coopNetLocalIsLeft) {
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
if (ks[leftRightKey]) b |= coopnet::MoveRight;
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
} else {
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
if (ks[rightRightKey]) b |= coopnet::MoveRight;
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
}
b |= ctx.coopNetPendingButtons;
ctx.coopNetPendingButtons = 0;
return b;
};
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
uint8_t buttons,
bool& leftHeldPrev,
bool& rightHeldPrev,
double& timer) {
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
coopGame->setSoftDropping(side, downHeldNow);
int moveDir = 0;
if (leftHeldNow && !rightHeldNow) moveDir = -1;
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
if (moveDir != 0) {
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
coopGame->move(side, moveDir);
timer = DAS;
} else {
timer -= FIXED_DT_MS;
if (timer <= 0.0) {
coopGame->move(side, moveDir);
timer += ARR;
}
}
} else {
timer = 0.0;
}
if (coopnet::has(buttons, coopnet::RotCW)) {
coopGame->rotate(side, +1);
}
if (coopnet::has(buttons, coopnet::RotCCW)) {
coopGame->rotate(side, -1);
}
if (coopnet::has(buttons, coopnet::HardDrop)) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
coopGame->hardDrop(side);
}
if (coopnet::has(buttons, coopnet::Hold)) {
coopGame->holdCurrent(side);
}
leftHeldPrev = leftHeldNow;
rightHeldPrev = rightHeldNow;
};
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
int safetySteps = 0;
bool advancedTick = false;
ctx.coopNetStalled = false;
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
const uint32_t tick = ctx.coopNetTick;
if (coopNetCachedTick != tick) {
coopNetCachedTick = tick;
coopNetCachedButtons = buildLocalButtons();
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
roleStr,
tick);
}
}
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
if (!remoteButtonsOpt.has_value()) {
if (!ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL begin waitingForTick=%u",
roleStr,
tick);
}
ctx.coopNetStalled = true;
break; // lockstep stall
}
const uint8_t remoteButtons = remoteButtonsOpt.value();
const bool localIsLeft = ctx.coopNetLocalIsLeft;
if (localIsLeft) {
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
} else {
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
}
coopGame->tickGravity(FIXED_DT_MS);
coopGame->updateVisualEffects(FIXED_DT_MS);
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
coopNetLastHashSentTick = tick;
const uint64_t hash = coopGame->computeStateHash();
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
roleStr,
tick,
(unsigned long long)hash);
}
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
if (rh.has_value() && rh.value() != hash) {
ctx.coopNetDesyncDetected = true;
ctx.coopNetUiStatusText = "NET DESYNC";
ctx.coopNetUiStatusRemainingMs = 8000.0;
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
roleStr,
tick,
(unsigned long long)hash,
(unsigned long long)rh.value());
game->setPaused(true);
}
}
ctx.coopNetTick++;
advancedTick = true;
coopNetAccMs -= FIXED_DT_MS;
}
if (advancedTick) {
if (ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL end atTick=%u",
roleStr,
ctx.coopNetTick);
}
ctx.coopNetStalled = false;
}
} else if (!coopVsAI) {
// Standard two-player: left uses WASD, right uses arrow keys
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
p1LeftHeld = ks[leftLeftKey];
p1RightHeld = ks[leftRightKey];
p2LeftHeld = ks[rightLeftKey];
p2RightHeld = ks[rightRightKey];
} else {
// Coop vs CPU: AI controls LEFT, human controls RIGHT (arrow keys).
// Handle continuous input for the human on the right side.
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
// Mirror the human soft-drop to the AI-controlled left board so both fall together.
const bool pRightSoftDrop = ks[rightDownKey];
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, pRightSoftDrop);
// Reset left continuous timers/held flags (AI handles movement)
p1MoveTimerMs = 0.0;
p1LeftHeld = false;
p1RightHeld = false;
// Update AI for the left side
coopAI.update(*coopGame, CoopGame::PlayerSide::Left, frameMs);
// Update human-held flags for right-side controls so DAS/ARR state is tracked
p2LeftHeld = ks[rightLeftKey];
p2RightHeld = ks[rightRightKey];
}
if (!coopNetActive) {
coopGame->tickGravity(frameMs);
coopGame->updateVisualEffects(frameMs);
} }
} }
}
if (game->isGameOver()) if (coopGame->isGameOver()) {
{ // Compute combined coop stats for Game Over
if (game->score() > 0) { int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
isNewHighScore = true; int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
playerName.clear(); int combinedScore = leftScore + rightScore;
SDL_StartTextInput(window); if (combinedScore > 0) {
} else { isNewHighScore = true;
isNewHighScore = false; if (coopVsAI) {
ensureScoresLoaded(); // AI is left, prompt human (right) for name
scores.submit(game->score(), game->lines(), game->level(), game->elapsed()); playerName = "CPU";
player2Name.clear();
highScoreEntryIndex = 1; // enter P2 (human)
} else {
playerName.clear();
player2Name.clear();
highScoreEntryIndex = 0;
}
SDL_StartTextInput(window);
} else {
isNewHighScore = false;
ensureScoresLoaded();
// When AI is present, label should indicate CPU left and human right
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), coopVsAI ? "CPU & P2" : "P1 & P2", "cooperate");
}
state = AppState::GameOver;
stateMgr->setState(state);
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = false;
}
} else {
if (!game->isPaused()) {
game->tickGravity(frameMs);
game->updateElapsedTime();
if (lineEffect.isActive()) {
if (lineEffect.update(frameMs / 1000.0f)) {
game->clearCompletedLines();
}
}
}
if (game->isGameOver())
{
if (game->score() > 0) {
isNewHighScore = true;
playerName.clear();
SDL_StartTextInput(window);
} else {
isNewHighScore = false;
ensureScoresLoaded();
{
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt);
}
}
state = AppState::GameOver;
stateMgr->setState(state);
} }
state = AppState::GameOver;
stateMgr->setState(state);
} }
} }
else if (state == AppState::Loading) else if (state == AppState::Loading)
@ -1346,7 +1854,15 @@ void TetrisApp::Impl::runLoop()
if (totalTasks > 0) { if (totalTasks > 0) {
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks)); loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
if (loadingProgress >= 1.0 && musicLoaded) { if (loadingProgress >= 1.0 && musicLoaded) {
state = AppState::Menu; startupFadeActive = false;
startupFadeAlpha = 0.0f;
startupFadeClockMs = 0.0;
if (std::filesystem::exists(introVideoPath)) {
state = AppState::Video;
} else {
state = AppState::Menu;
}
stateMgr->setState(state); stateMgr->setState(state);
} }
} else { } else {
@ -1374,7 +1890,15 @@ void TetrisApp::Impl::runLoop()
if (loadingProgress > 0.99) loadingProgress = 1.0; if (loadingProgress > 0.99) loadingProgress = 1.0;
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0; if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
if (loadingProgress >= 1.0 && musicLoaded) { if (loadingProgress >= 1.0 && musicLoaded) {
state = AppState::Menu; startupFadeActive = false;
startupFadeAlpha = 0.0f;
startupFadeClockMs = 0.0;
if (std::filesystem::exists(introVideoPath)) {
state = AppState::Video;
} else {
state = AppState::Menu;
}
stateMgr->setState(state); stateMgr->setState(state);
} }
} }
@ -1441,6 +1965,9 @@ void TetrisApp::Impl::runLoop()
case AppState::Loading: case AppState::Loading:
loadingState->update(frameMs); loadingState->update(frameMs);
break; break;
case AppState::Video:
if (videoState) videoState->update(frameMs);
break;
case AppState::Menu: case AppState::Menu:
menuState->update(frameMs); menuState->update(frameMs);
break; break;
@ -1743,6 +2270,11 @@ void TetrisApp::Impl::runLoop()
} }
} }
break; break;
case AppState::Video:
if (videoState) {
videoState->render(renderer, logicalScale, logicalVP);
}
break;
case AppState::Menu: case AppState::Menu:
if (!mainScreenTex) { if (!mainScreenTex) {
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
@ -1866,32 +2398,44 @@ void TetrisApp::Impl::runLoop()
SDL_RenderFillRect(renderer, &boxRect); SDL_RenderFillRect(renderer, &boxRect);
ensureScoresLoaded(); ensureScoresLoaded();
bool realHighScore = scores.isHighScore(game->score()); // Choose display values based on mode (single-player vs coop)
int displayScore = 0;
int displayLines = 0;
int displayLevel = 0;
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
displayScore = leftScore + rightScore;
displayLines = coopGame->lines();
displayLevel = coopGame->level();
} else if (game) {
displayScore = game->score();
displayLines = game->lines();
displayLevel = game->level();
}
bool realHighScore = scores.isHighScore(displayScore);
const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER"; const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER";
int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH); int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH);
pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255}); pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255});
char scoreStr[64]; char scoreStr[64];
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game->score()); snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", displayScore);
int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH); int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH);
pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255}); pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255});
if (isNewHighScore) { if (isNewHighScore) {
const char* enterName = "ENTER NAME:"; const bool isCoopEntry = (game && game->getMode() == GameMode::Cooperate && coopGame);
const char* enterName = isCoopEntry ? "ENTER NAMES:" : "ENTER NAME:";
int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH); int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH);
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255}); if (!isCoopEntry) {
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
}
float inputW = 300.0f; const float inputW = isCoopEntry ? 260.0f : 300.0f;
float inputH = 40.0f; const float inputH = 40.0f;
float inputX = boxX + (boxW - inputW) * 0.5f; const float inputX = boxX + (boxW - inputW) * 0.5f;
float inputY = boxY + 200.0f; const float inputY = boxY + 200.0f;
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
SDL_RenderFillRect(renderer, &inputRect);
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
SDL_RenderRect(renderer, &inputRect);
const float nameScale = 1.2f; const float nameScale = 1.2f;
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0; const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
@ -1900,34 +2444,67 @@ void TetrisApp::Impl::runLoop()
pixelFont.measure("A", nameScale, metricsW, metricsH); pixelFont.measure("A", nameScale, metricsW, metricsH);
if (metricsH == 0) metricsH = 24; if (metricsH == 0) metricsH = 24;
int nameW = 0, nameH = 0; // Single name entry (non-coop) --- keep original behavior
if (!playerName.empty()) { if (!isCoopEntry) {
pixelFont.measure(playerName, nameScale, nameW, nameH); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
SDL_RenderFillRect(renderer, &inputRect);
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
SDL_RenderRect(renderer, &inputRect);
int nameW = 0, nameH = 0;
if (!playerName.empty()) pixelFont.measure(playerName, nameScale, nameW, nameH);
else nameH = metricsH;
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
if (!playerName.empty()) pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255,255,255,255});
if (showCursor) {
int cursorW = 0, cursorH = 0; pixelFont.measure("_", nameScale, cursorW, cursorH);
float cursorX = playerName.empty() ? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX : textX + static_cast<float>(nameW);
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255});
}
const char* hint = "PRESS ENTER TO SUBMIT";
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
} else { } else {
nameH = metricsH; // Coop: prompt sequentially. First ask Player 1, then ask Player 2 after Enter.
const bool askingP1 = (highScoreEntryIndex == 0);
const char* label = askingP1 ? "PLAYER 1:" : "PLAYER 2:";
int labW=0, labH=0; pixelFont.measure(label, 1.0f, labW, labH);
pixelFont.draw(renderer, boxX + (boxW - labW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, label, 1.0f, {200,200,220,255});
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect rect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
SDL_RenderFillRect(renderer, &rect);
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
SDL_RenderRect(renderer, &rect);
const std::string &activeName = askingP1 ? playerName : player2Name;
int nameW = 0, nameH = 0;
if (!activeName.empty()) pixelFont.measure(activeName, nameScale, nameW, nameH);
else nameH = metricsH;
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
if (!activeName.empty()) pixelFont.draw(renderer, textX, textY, activeName, nameScale, {255,255,255,255});
if (showCursor) {
int cursorW=0, cursorH=0; pixelFont.measure("_", nameScale, cursorW, cursorH);
float cursorX = activeName.empty() ? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX : textX + static_cast<float>(nameW);
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255});
}
const char* hint = askingP1 ? "PRESS ENTER FOR NEXT NAME" : "PRESS ENTER TO SUBMIT";
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 300 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
} }
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
if (!playerName.empty()) {
pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255});
}
if (showCursor) {
int cursorW = 0, cursorH = 0;
pixelFont.measure("_", nameScale, cursorW, cursorH);
float cursorX = playerName.empty()
? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX
: textX + static_cast<float>(nameW);
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255});
}
const char* hint = "PRESS ENTER TO SUBMIT";
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
} else { } else {
char linesStr[64]; char linesStr[64];
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines()); snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines());
@ -2091,6 +2668,17 @@ void TetrisApp::Impl::runLoop()
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY); HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
} }
if (startupFadeActive && startupFadeAlpha > 0.0f) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const Uint8 a = (Uint8)std::clamp((int)std::lround(startupFadeAlpha * 255.0f), 0, 255);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &full);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}
SDL_RenderPresent(renderer); SDL_RenderPresent(renderer);
SDL_SetRenderScale(renderer, 1.f, 1.f); SDL_SetRenderScale(renderer, 1.f, 1.f);
} }

View File

@ -21,7 +21,11 @@ std::string Settings::getSettingsPath() {
bool Settings::load() { bool Settings::load() {
std::ifstream file(getSettingsPath()); std::ifstream file(getSettingsPath());
if (!file.is_open()) { if (!file.is_open()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults"); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults. Creating settings file with defaults.");
// Persist defaults so next run has an explicit settings.ini
try {
save();
} catch (...) {}
return false; return false;
} }

View File

@ -48,7 +48,8 @@ private:
Settings& operator=(const Settings&) = delete; Settings& operator=(const Settings&) = delete;
// Settings values // Settings values
bool m_fullscreen = false; // Default to fullscreen on first run when no settings.ini exists
bool m_fullscreen = true;
bool m_musicEnabled = true; bool m_musicEnabled = true;
bool m_soundEnabled = true; bool m_soundEnabled = true;
bool m_debugEnabled = false; bool m_debugEnabled = false;

View File

@ -25,15 +25,26 @@
#include "../../graphics/effects/Starfield.h" #include "../../graphics/effects/Starfield.h"
#include "../../graphics/renderers/GameRenderer.h" #include "../../graphics/renderers/GameRenderer.h"
#include "../../gameplay/core/Game.h" #include "../../gameplay/core/Game.h"
#include "../../gameplay/coop/CoopGame.h"
#include "../../gameplay/effects/LineEffect.h" #include "../../gameplay/effects/LineEffect.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include "../../utils/ImagePathResolver.h" #include "../../utils/ImagePathResolver.h"
#include <iostream> #include <iostream>
#include "../../video/VideoPlayer.h"
#include <cmath> #include <cmath>
#include <fstream> #include <fstream>
#include <algorithm> #include <algorithm>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <shellapi.h>
#endif
// (Intro video playback is now handled in-process via VideoPlayer)
ApplicationManager::ApplicationManager() = default; ApplicationManager::ApplicationManager() = default;
@ -54,7 +65,15 @@ void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& r
if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual); if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
app->m_starfield3D->draw(renderer.getSDLRenderer()); app->m_starfield3D->draw(renderer.getSDLRenderer());
} }
// If intro video is playing, render it instead of the loading UI
if (app->m_introStarted && app->m_videoPlayer) {
SDL_Renderer* sdlR = renderer.getSDLRenderer();
int winW=0, winH=0; renderer.getWindowSize(winW, winH);
app->m_videoPlayer->render(sdlR, winW, winH);
SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr);
SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f);
return;
}
SDL_Rect logicalVP = {0,0,0,0}; SDL_Rect logicalVP = {0,0,0,0};
float logicalScale = 1.0f; float logicalScale = 1.0f;
if (app->m_renderManager) { if (app->m_renderManager) {
@ -561,6 +580,7 @@ bool ApplicationManager::initializeGame() {
m_lineEffect->init(m_renderManager->getSDLRenderer()); m_lineEffect->init(m_renderManager->getSDLRenderer());
} }
m_game = std::make_unique<Game>(m_startLevelSelection); m_game = std::make_unique<Game>(m_startLevelSelection);
m_coopGame = std::make_unique<CoopGame>(m_startLevelSelection);
// Wire up sound callbacks as main.cpp did // Wire up sound callbacks as main.cpp did
if (m_game) { if (m_game) {
// Apply global gravity speed multiplier from config // Apply global gravity speed multiplier from config
@ -580,6 +600,18 @@ 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 // Prepare a StateContext-like struct by setting up handlers that capture
// pointers and flags. State objects in this refactor expect these to be // pointers and flags. State objects in this refactor expect these to be
// available via StateManager event/update/render hooks, so we'll store them // available via StateManager event/update/render hooks, so we'll store them
@ -621,6 +653,7 @@ bool ApplicationManager::initializeGame() {
{ {
m_stateContext.stateManager = m_stateManager.get(); m_stateContext.stateManager = m_stateManager.get();
m_stateContext.game = m_game.get(); m_stateContext.game = m_game.get();
m_stateContext.coopGame = m_coopGame.get();
m_stateContext.scores = m_scoreManager.get(); m_stateContext.scores = m_scoreManager.get();
m_stateContext.starfield = m_starfield.get(); m_stateContext.starfield = m_starfield.get();
m_stateContext.starfield3D = m_starfield3D.get(); m_stateContext.starfield3D = m_starfield3D.get();
@ -765,17 +798,44 @@ void ApplicationManager::setupStateHandlers() {
m_starfield3D->update(deltaTime / 1000.0f); m_starfield3D->update(deltaTime / 1000.0f);
} }
// Check if loading is complete and transition to menu // Check if loading is complete and transition to next stage
if (m_assetManager->isLoadingComplete()) { if (m_assetManager->isLoadingComplete()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu"); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow");
// Update texture pointers now that assets are loaded // Update texture pointers now that assets are loaded
m_stateContext.backgroundTex = m_assetManager->getTexture("background"); m_stateContext.backgroundTex = m_assetManager->getTexture("background");
m_stateContext.blocksTex = m_assetManager->getTexture("blocks"); m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
bool ok = m_stateManager->setState(AppState::Menu); // If an intro video exists and hasn't been started, attempt to play it in-process
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0); std::filesystem::path introPath = m_introPath;
traceFile("- to Menu returned"); if (!m_introStarted && std::filesystem::exists(introPath)) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str());
try {
if (!m_videoPlayer) m_videoPlayer = std::make_unique<VideoPlayer>();
SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr;
if (m_videoPlayer->open(introPath.string(), sdlRend)) {
m_introStarted = true;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process");
} else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping");
m_stateManager->setState(AppState::Playing);
}
} catch (const std::exception& ex) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what());
m_stateManager->setState(AppState::Playing);
}
} else if (m_introStarted) {
// Let VideoPlayer decode frames; once finished, transition to playing
if (m_videoPlayer) m_videoPlayer->update();
if (!m_videoPlayer || m_videoPlayer->isFinished()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing");
m_stateManager->setState(AppState::Playing);
}
} else {
// No intro to play; transition directly to Playing
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing");
m_stateManager->setState(AppState::Playing);
}
} }
}); });
@ -917,8 +977,8 @@ void ApplicationManager::setupStateHandlers() {
m_showExitConfirmPopup = true; m_showExitConfirmPopup = true;
return; return;
} }
// S: toggle SFX enable state (music handled globally) // K: toggle SFX enable state (music handled globally)
if (event.key.scancode == SDL_SCANCODE_S) { if (event.key.scancode == SDL_SCANCODE_K) {
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled()); SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
} }
} }
@ -1217,13 +1277,25 @@ void ApplicationManager::setupStateHandlers() {
// "GAME OVER" title // "GAME OVER" title
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, {255, 80, 60, 255}); 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]; char buf[128];
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", if (m_stateContext.game && m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame) {
m_stateContext.game->score(), int leftScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Left);
m_stateContext.game->lines(), int rightScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Right);
m_stateContext.game->level()); int total = leftScore + rightScore;
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 180, 220, buf, 1.2f, {220, 220, 230, 255}); 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 // Instructions
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255}); font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255});
@ -1237,74 +1309,160 @@ void ApplicationManager::setupStateHandlers() {
m_stateManager->registerUpdateHandler(AppState::Playing, m_stateManager->registerUpdateHandler(AppState::Playing,
[this](double frameMs) { [this](double frameMs) {
if (!m_stateContext.game) return; if (!m_stateContext.game) return;
const bool coopActive = m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame;
// Get current keyboard state // Get current keyboard state
const bool *ks = SDL_GetKeyboardState(nullptr); 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) if (coopActive) {
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets) // Paused: suppress all continuous input so pieces don't drift while paused.
const float LEVEL_FADE_DURATION = 1200.0f; if (m_stateContext.game->isPaused()) {
if (m_nextLevelBackgroundTex) { m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
m_levelFadeElapsed += (float)frameMs; m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION); m_p1MoveTimerMs = 0.0;
} m_p2MoveTimerMs = 0.0;
m_p1LeftHeld = false;
// Check for game over and transition to GameOver state m_p1RightHeld = false;
if (m_stateContext.game->isGameOver()) { m_p2LeftHeld = false;
// Submit score before transitioning m_p2RightHeld = false;
if (m_stateContext.scores) { return;
m_stateContext.scores->submit( }
m_stateContext.game->score(),
m_stateContext.game->lines(), auto handleSide = [&](CoopGame::PlayerSide side,
m_stateContext.game->level(), bool leftHeld,
m_stateContext.game->elapsed() 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 // Debug overlay: show current window and logical sizes on the right side of the screen

View File

@ -17,6 +17,7 @@ class Starfield;
class Starfield3D; class Starfield3D;
class FontAtlas; class FontAtlas;
class LineEffect; class LineEffect;
class CoopGame;
// Forward declare state classes (top-level, defined under src/states) // Forward declare state classes (top-level, defined under src/states)
class LoadingState; class LoadingState;
@ -109,6 +110,7 @@ private:
std::unique_ptr<ScoreManager> m_scoreManager; std::unique_ptr<ScoreManager> m_scoreManager;
// Gameplay pieces // Gameplay pieces
std::unique_ptr<Game> m_game; std::unique_ptr<Game> m_game;
std::unique_ptr<CoopGame> m_coopGame;
std::unique_ptr<LineEffect> m_lineEffect; std::unique_ptr<LineEffect> m_lineEffect;
// DAS/ARR movement timing (from original main.cpp) // 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 DAS = 170.0; // Delayed Auto Shift
static constexpr double ARR = 40.0; // Auto Repeat Rate 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) // State context (must be a member to ensure lifetime)
StateContext m_stateContext; StateContext m_stateContext;
@ -143,6 +153,11 @@ private:
float m_logoAnimCounter = 0.0f; float m_logoAnimCounter = 0.0f;
bool m_helpOverlayPausedGame = false; bool m_helpOverlayPausedGame = false;
// Intro video playback (in-process via FFmpeg)
bool m_introStarted = false;
std::string m_introPath = "assets/videos/spacetris_intro.mp4";
std::unique_ptr<class VideoPlayer> m_videoPlayer;
// Gameplay background (per-level) with fade, mirroring main.cpp behavior // Gameplay background (per-level) with fade, mirroring main.cpp behavior
SDL_Texture* m_levelBackgroundTex = nullptr; SDL_Texture* m_levelBackgroundTex = nullptr;
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions

View File

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

View File

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

View File

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

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

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

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

View File

@ -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 using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
// Game runtime mode // Game runtime mode
enum class GameMode { Endless, Challenge }; enum class GameMode { Endless, Cooperate, Challenge };
// Special obstacle blocks used by Challenge mode // Special obstacle blocks used by Challenge mode
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 }; enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };

View File

@ -188,10 +188,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; if (rows.empty()) return;
clearingRows = rows; clearingRows = rows;
effectGridCols = std::max(1, gridCols);
effectGapPx = std::max(0, gapPx);
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
state = AnimationState::FLASH_WHITE; state = AnimationState::FLASH_WHITE;
timer = 0.0f; timer = 0.0f;
dropProgress = 0.0f; dropProgress = 0.0f;
@ -228,8 +231,11 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) { void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
const float centerY = gridY + row * blockSize + blockSize * 0.5f; 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; float centerX = gridX + col * blockSize + blockSize * 0.5f;
if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) {
centerX += static_cast<float>(effectGapPx);
}
SDL_Color tint = pickFireColor(); SDL_Color tint = pickFireColor();
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint); spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
spawnShardBurst(centerX, centerY, tint); spawnShardBurst(centerX, centerY, tint);
@ -337,8 +343,12 @@ void LineEffect::updateGlowPulses(float dt) {
glowPulses.end()); 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; 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) { switch (state) {
case AnimationState::FLASH_WHITE: case AnimationState::FLASH_WHITE:
@ -383,10 +393,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
for (int row : clearingRows) { for (int row : clearingRows) {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha); SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0;
SDL_FRect flashRect = { SDL_FRect flashRect = {
static_cast<float>(gridX - 4), static_cast<float>(gridX - 4),
static_cast<float>(gridY + row * blockSize - 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) static_cast<float>(blockSize + 8)
}; };
SDL_RenderFillRect(renderer, &flashRect); SDL_RenderFillRect(renderer, &flashRect);

View File

@ -69,11 +69,11 @@ public:
void shutdown(); void shutdown();
// Start line clear effect for the specified rows // 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 // Update and render the effect
bool update(float deltaTime); // Returns true if effect is complete 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; float getRowDropOffset(int row) const;
// Audio // Audio
@ -120,4 +120,7 @@ private:
std::array<float, Game::ROWS> rowDropTargets{}; std::array<float, Game::ROWS> rowDropTargets{};
float dropProgress = 0.0f; float dropProgress = 0.0f;
int dropBlockSize = 0; int dropBlockSize = 0;
int effectGridCols = Game::COLS;
int effectGapPx = 0;
int effectGapAfterCol = 0;
}; };

View File

@ -1,5 +1,9 @@
#include "GameRenderer.h" #include "GameRenderer.h"
#include "SyncLineRenderer.h"
#include "../../gameplay/core/Game.h" #include "../../gameplay/core/Game.h"
#include "../../gameplay/coop/CoopGame.h"
#include "../../app/Fireworks.h"
#include "../ui/Font.h" #include "../ui/Font.h"
#include "../../gameplay/effects/LineEffect.h" #include "../../gameplay/effects/LineEffect.h"
#include <algorithm> #include <algorithm>
@ -693,6 +697,11 @@ void GameRenderer::renderPlayingState(
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) { if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
auto completedLines = game->getCompletedLines(); auto completedLines = game->getCompletedLines();
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize)); lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
// Trigger fireworks visually for a 4-line clear (TETRIS)
if (completedLines.size() == 4) {
// spawn near center of grid
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
}
} }
// Draw game grid border // Draw game grid border
@ -1356,6 +1365,26 @@ void GameRenderer::renderPlayingState(
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed); activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
} }
// Debug: log single-player smoothing/fall values when enabled
if (Settings::instance().isDebugEnabled()) {
float sp_targetX = static_cast<float>(game->current().x);
double sp_gravityMs = game->getGravityMs();
double sp_fallAcc = game->getFallAccumulator();
int sp_soft = game->isSoftDropping() ? 1 : 0;
/*
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
(unsigned long long)s_activePieceSmooth.sequence,
s_activePieceSmooth.visualX,
sp_targetX,
activePiecePixelOffsetX,
activePiecePixelOffsetY,
sp_gravityMs,
sp_fallAcc,
sp_soft
);
*/
}
// Draw ghost piece (where current piece will land) // Draw ghost piece (where current piece will land)
if (allowActivePieceRender) { if (allowActivePieceRender) {
Game::Piece ghostPiece = game->current(); Game::Piece ghostPiece = game->current();
@ -1806,6 +1835,929 @@ void GameRenderer::renderPlayingState(
// Exit popup logic moved to renderExitPopup // Exit popup logic moved to renderExitPopup
} }
void GameRenderer::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
) {
if (!renderer || !game || !pixelFont) return;
static SyncLineRenderer s_syncLine;
static bool s_lastHadCompletedLines = false;
static Uint32 s_lastCoopTick = SDL_GetTicks();
Uint32 nowTicks = SDL_GetTicks();
float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
s_lastCoopTick = nowTicks;
if (deltaMs < 0.0f || deltaMs > 100.0f) {
deltaMs = 16.0f;
}
const float deltaSeconds = std::clamp(deltaMs / 1000.0f, 0.0f, 0.033f);
s_syncLine.Update(deltaSeconds);
const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
struct SmoothState { bool initialized{false}; uint64_t seq{0}; float visualX{0.0f}; float visualY{0.0f}; };
static SmoothState s_leftSmooth{};
static SmoothState s_rightSmooth{};
struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; int spawnY{0}; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; };
static SpawnFadeState s_leftSpawnFade{};
static SpawnFadeState s_rightSpawnFade{};
// Layout constants (reuse single-player feel but sized for 20 cols)
const float MIN_MARGIN = 40.0f;
const float TOP_MARGIN = 60.0f;
const float PANEL_WIDTH = 180.0f;
const float PANEL_SPACING = 30.0f;
const float NEXT_PANEL_HEIGHT = 120.0f;
const float BOTTOM_MARGIN = 60.0f;
// Content offset (centered logical viewport inside window)
float contentScale = logicalScale;
float contentW = logicalW * contentScale;
float contentH = logicalH * contentScale;
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
SDL_RenderFillRect(renderer, &fr);
};
static constexpr float COOP_GAP_PX = 20.0f;
const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PANEL_HEIGHT;
const float usableGridWidth = std::max(0.0f, availableWidth - COOP_GAP_PX);
const float maxBlockSizeW = usableGridWidth / CoopGame::COLS;
const float maxBlockSizeH = availableHeight / CoopGame::ROWS;
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
const float finalBlockSize = std::max(16.0f, std::min(BLOCK_SIZE, 36.0f));
const float HALF_W = 10.0f * finalBlockSize;
const float GRID_W = CoopGame::COLS * finalBlockSize + COOP_GAP_PX;
const float GRID_H = CoopGame::ROWS * finalBlockSize;
const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
const float statsX = layoutStartX + contentOffsetX;
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
const float gridY = contentStartY + NEXT_PANEL_HEIGHT + contentOffsetY;
const float rightPanelX = gridX + GRID_W + PANEL_SPACING;
const float statsY = gridY;
const float statsW = PANEL_WIDTH;
const float statsH = GRID_H;
// (Score panels are drawn per-player below using scorePanelTex and classic sizing.)
// Handle line clearing effects (defer to LineEffect like single-player)
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
auto completedLines = game->getCompletedLines();
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), CoopGame::COLS, static_cast<int>(COOP_GAP_PX), 10);
if (completedLines.size() == 4) {
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
}
}
// Precompute row drop offsets (line collapse effect)
std::array<float, CoopGame::ROWS> rowDropOffsets{};
for (int y = 0; y < CoopGame::ROWS; ++y) {
rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
}
// Grid backdrop and border (one border around both halves)
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
// Background for left+right halves
drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
// Gap background (slightly darker so the 10px separation reads clearly)
drawRectWithOffset(gridX + HALF_W - contentOffsetX, gridY - contentOffsetY, COOP_GAP_PX, GRID_H, {12, 14, 18, 255});
// Sync divider line centered in the gap between halves.
const float dividerCenterX = gridX + HALF_W + (COOP_GAP_PX * 0.5f);
s_syncLine.SetRect(SDL_FRect{ dividerCenterX - 2.0f, gridY, 4.0f, GRID_H });
auto cellX = [&](int col) -> float {
float x = gridX + col * finalBlockSize;
if (col >= 10) {
x += COOP_GAP_PX;
}
return x;
};
// Grid lines (draw per-half so the gap is clean)
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
for (int x = 1; x < 10; ++x) {
float lineX = gridX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
}
for (int x = 1; x < 10; ++x) {
float lineX = gridX + HALF_W + COOP_GAP_PX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
}
for (int y = 1; y < CoopGame::ROWS; ++y) {
float lineY = gridY + y * finalBlockSize;
SDL_RenderLine(renderer, gridX, lineY, gridX + HALF_W, lineY);
SDL_RenderLine(renderer, gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY);
}
// In-grid 3D starfield + ambient sparkles (match classic feel, per-half)
{
static Uint32 s_lastCoopSparkTick = SDL_GetTicks();
static std::mt19937 s_coopSparkRng{ std::random_device{}() };
static std::vector<Sparkle> s_leftSparkles;
static std::vector<Sparkle> s_rightSparkles;
static std::vector<ImpactSpark> s_leftImpactSparks;
static std::vector<ImpactSpark> s_rightImpactSparks;
static float s_leftSparkleSpawnAcc = 0.0f;
static float s_rightSparkleSpawnAcc = 0.0f;
float halfW = HALF_W;
const float leftGridX = gridX;
const float rightGridX = gridX + HALF_W + COOP_GAP_PX;
Uint32 sparkNow = nowTicks;
float sparkDeltaMs = static_cast<float>(sparkNow - s_lastCoopSparkTick);
s_lastCoopSparkTick = sparkNow;
if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) {
sparkDeltaMs = 16.0f;
}
if (!s_starfieldInitialized) {
s_inGridStarfield.init(static_cast<int>(halfW), static_cast<int>(GRID_H), 180);
s_starfieldInitialized = true;
} else {
s_inGridStarfield.resize(static_cast<int>(halfW), static_cast<int>(GRID_H));
}
const float deltaSeconds = std::clamp(sparkDeltaMs / 1000.0f, 0.0f, 0.033f);
s_inGridStarfield.update(deltaSeconds);
struct MagnetInfo { bool active{false}; float x{0.0f}; float y{0.0f}; };
auto computeMagnet = [&](CoopGame::PlayerSide side) -> MagnetInfo {
MagnetInfo info{};
const CoopGame::Piece& activePiece = game->current(side);
const int pieceType = static_cast<int>(activePiece.type);
if (pieceType < 0 || pieceType >= PIECE_COUNT) {
return info;
}
float sumLocalX = 0.0f;
float sumLocalY = 0.0f;
int filledCells = 0;
const int localXOffsetCols = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(activePiece, cx, cy)) continue;
sumLocalX += ((activePiece.x - localXOffsetCols) + cx + 0.5f) * finalBlockSize;
sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize;
++filledCells;
}
}
if (filledCells <= 0) {
return info;
}
info.active = true;
info.x = std::clamp(sumLocalX / static_cast<float>(filledCells), 0.0f, halfW);
info.y = std::clamp(sumLocalY / static_cast<float>(filledCells), 0.0f, GRID_H);
return info;
};
const MagnetInfo leftMagnet = computeMagnet(CoopGame::PlayerSide::Left);
const MagnetInfo rightMagnet = computeMagnet(CoopGame::PlayerSide::Right);
SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE;
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
auto drawStarfieldHalf = [&](float originX, const MagnetInfo& magnet) {
if (magnet.active) {
const float magnetStrength = finalBlockSize * 2.2f;
s_inGridStarfield.setMagnetTarget(magnet.x, magnet.y, magnetStrength);
} else {
s_inGridStarfield.clearMagnetTarget();
}
const float jitterAmp = 1.6f;
const float tms = static_cast<float>(sparkNow) * 0.001f;
const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f;
const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f);
s_inGridStarfield.draw(renderer, originX + jitterX, gridY + jitterY, 0.22f, true);
};
drawStarfieldHalf(leftGridX, leftMagnet);
drawStarfieldHalf(rightGridX, rightMagnet);
auto updateAndDrawSparkleLayer = [&](std::vector<Sparkle>& sparkles,
std::vector<ImpactSpark>& impactSparks,
float& spawnAcc,
const MagnetInfo& magnet,
float originX) {
if (!paused) {
const float spawnInterval = 0.08f;
spawnAcc += deltaSeconds;
while (spawnAcc >= spawnInterval) {
spawnAcc -= spawnInterval;
Sparkle s;
bool spawnNearPiece = magnet.active && (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) > 0.35f);
float sx = 0.0f;
float sy = 0.0f;
if (spawnNearPiece) {
float jitterX = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
float jitterY = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
sx = std::clamp(magnet.x + jitterX, -finalBlockSize * 2.0f, halfW + finalBlockSize * 2.0f);
sy = std::clamp(magnet.y + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f);
} else {
float side = std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng);
const float borderBand = std::max(12.0f, finalBlockSize * 1.0f);
if (side < 0.2f) {
sx = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
} else if (side < 0.4f) {
sx = std::uniform_real_distribution<float>(halfW, halfW + borderBand)(s_coopSparkRng);
sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
} else if (side < 0.6f) {
sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
sy = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
} else if (side < 0.9f) {
sx = std::uniform_real_distribution<float>(0.0f, halfW)(s_coopSparkRng);
sy = std::uniform_real_distribution<float>(0.0f, finalBlockSize * 2.0f)(s_coopSparkRng);
} else {
sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
sy = std::uniform_real_distribution<float>(GRID_H, GRID_H + borderBand)(s_coopSparkRng);
}
}
s.x = sx;
s.y = sy;
float speed = std::uniform_real_distribution<float>(10.0f, 60.0f)(s_coopSparkRng);
float ang = std::uniform_real_distribution<float>(-3.14159f, 3.14159f)(s_coopSparkRng);
s.vx = std::cos(ang) * speed;
s.vy = std::sin(ang) * speed * 0.25f;
s.maxLifeMs = std::uniform_real_distribution<float>(350.0f, 900.0f)(s_coopSparkRng);
s.lifeMs = s.maxLifeMs;
s.size = std::uniform_real_distribution<float>(1.5f, 5.0f)(s_coopSparkRng);
if (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) < 0.5f) {
s.color = SDL_Color{255, 230, 180, 255};
} else {
s.color = SDL_Color{180, 220, 255, 255};
}
s.pulse = std::uniform_real_distribution<float>(0.0f, 6.28f)(s_coopSparkRng);
sparkles.push_back(s);
}
}
if (!sparkles.empty()) {
auto it = sparkles.begin();
while (it != sparkles.end()) {
Sparkle& sp = *it;
sp.lifeMs -= sparkDeltaMs;
if (sp.lifeMs <= 0.0f) {
const int burstCount = std::uniform_int_distribution<int>(4, 8)(s_coopSparkRng);
for (int bi = 0; bi < burstCount; ++bi) {
ImpactSpark ps;
ps.x = originX + sp.x + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
ps.y = gridY + sp.y + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
float ang = std::uniform_real_distribution<float>(0.0f, 6.2831853f)(s_coopSparkRng);
float speed = std::uniform_real_distribution<float>(10.0f, 120.0f)(s_coopSparkRng);
ps.vx = std::cos(ang) * speed;
ps.vy = std::sin(ang) * speed * 0.8f;
ps.maxLifeMs = std::uniform_real_distribution<float>(220.0f, 500.0f)(s_coopSparkRng);
ps.lifeMs = ps.maxLifeMs;
ps.size = std::max(1.0f, sp.size * 0.5f);
ps.color = sp.color;
impactSparks.push_back(ps);
}
it = sparkles.erase(it);
continue;
}
float lifeRatio = sp.lifeMs / sp.maxLifeMs;
sp.x += sp.vx * deltaSeconds;
sp.y += sp.vy * deltaSeconds;
sp.vy *= 0.995f;
sp.pulse += deltaSeconds * 8.0f;
float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha);
float half = sp.size * 0.5f;
SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size };
SDL_RenderFillRect(renderer, &fr);
++it;
}
}
if (!impactSparks.empty()) {
auto it = impactSparks.begin();
while (it != impactSparks.end()) {
ImpactSpark& spark = *it;
spark.vy += 0.00045f * sparkDeltaMs;
spark.x += spark.vx * sparkDeltaMs;
spark.y += spark.vy * sparkDeltaMs;
spark.lifeMs -= sparkDeltaMs;
if (spark.lifeMs <= 0.0f) {
it = impactSparks.erase(it);
continue;
}
float lifeRatio = spark.lifeMs / spark.maxLifeMs;
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha);
SDL_FRect sparkRect{
spark.x - spark.size * 0.5f,
spark.y - spark.size * 0.5f,
spark.size,
spark.size * 1.4f
};
SDL_RenderFillRect(renderer, &sparkRect);
++it;
}
}
};
updateAndDrawSparkleLayer(s_leftSparkles, s_leftImpactSparks, s_leftSparkleSpawnAcc, leftMagnet, leftGridX);
updateAndDrawSparkleLayer(s_rightSparkles, s_rightImpactSparks, s_rightSparkleSpawnAcc, rightMagnet, rightGridX);
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
}
// Half-row feedback: lightly tint rows where one side is filled, brighter where both are pending clear
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const auto& rowStates = game->rowHalfStates();
bool leftReady = false;
bool rightReady = false;
bool synced = false;
for (int y = 0; y < CoopGame::ROWS; ++y) {
const auto& rs = rowStates[y];
float rowY = gridY + y * finalBlockSize;
if (rs.leftFull && rs.rightFull) {
synced = true;
} else {
leftReady = leftReady || (rs.leftFull && !rs.rightFull);
rightReady = rightReady || (rs.rightFull && !rs.leftFull);
}
if (rs.leftFull && rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45);
SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize};
SDL_RenderFillRect(renderer, &frL);
SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize};
SDL_RenderFillRect(renderer, &frR);
} else if (rs.leftFull ^ rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35);
float w = HALF_W;
float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX);
SDL_FRect fr{x, rowY, w, finalBlockSize};
SDL_RenderFillRect(renderer, &fr);
}
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
// Trigger a brief flash exactly when cooperative lines are actually cleared:
// `completedLines` remains populated during the LineEffect, then becomes empty
// immediately after `CoopGame::clearCompletedLines()` is invoked.
const bool hasCompletedLines = game->hasCompletedLines();
if (s_lastHadCompletedLines && !hasCompletedLines) {
s_syncLine.TriggerClearFlash();
}
s_lastHadCompletedLines = hasCompletedLines;
if (synced) {
s_syncLine.SetState(SyncState::Synced);
} else if (leftReady) {
s_syncLine.SetState(SyncState::LeftReady);
} else if (rightReady) {
s_syncLine.SetState(SyncState::RightReady);
} else {
s_syncLine.SetState(SyncState::Idle);
}
// Hard-drop impact shake (match classic feel)
float impactStrength = 0.0f;
float impactEased = 0.0f;
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> impactMask{};
std::array<float, CoopGame::COLS * CoopGame::ROWS> impactWeight{};
if (game->hasHardDropShake()) {
impactStrength = static_cast<float>(game->hardDropShakeStrength());
impactStrength = std::clamp(impactStrength, 0.0f, 1.0f);
impactEased = impactStrength * impactStrength;
const auto& impactCells = game->getHardDropCells();
const auto& boardRef = game->boardRef();
for (const auto& cell : impactCells) {
if (cell.x < 0 || cell.x >= CoopGame::COLS || cell.y < 0 || cell.y >= CoopGame::ROWS) {
continue;
}
int idx = cell.y * CoopGame::COLS + cell.x;
impactMask[idx] = 1;
impactWeight[idx] = 1.0f;
int depth = 0;
for (int ny = cell.y + 1; ny < CoopGame::ROWS && depth < 4; ++ny) {
if (!boardRef[ny * CoopGame::COLS + cell.x].occupied) {
break;
}
++depth;
int nidx = ny * CoopGame::COLS + cell.x;
impactMask[nidx] = 1;
float weight = std::max(0.15f, 1.0f - depth * 0.35f);
impactWeight[nidx] = std::max(impactWeight[nidx], weight);
}
}
}
// Draw settled blocks
const auto& board = game->boardRef();
for (int y = 0; y < CoopGame::ROWS; ++y) {
float dropOffset = rowDropOffsets[y];
for (int x = 0; x < CoopGame::COLS; ++x) {
const auto& cell = board[y * CoopGame::COLS + x];
if (!cell.occupied || cell.value <= 0) continue;
float px = cellX(x);
float py = gridY + y * finalBlockSize + dropOffset;
const int cellIdx = y * CoopGame::COLS + x;
float weight = impactWeight[cellIdx];
if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) {
float cellSeed = static_cast<float>((x * 37 + y * 61) % 113);
float t = static_cast<float>(nowTicks % 10000) * 0.018f + cellSeed;
float amplitude = 6.0f * impactEased * weight;
float freq = 2.0f + weight * 1.3f;
px += amplitude * std::sin(t * freq);
py += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
}
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1);
}
}
// Active pieces (per-side smoothing)
auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) {
float offsetX = 0.0f;
float offsetY = 0.0f;
if (smoothScrollEnabled) {
const uint64_t seq = game->currentPieceSequence(side);
const float targetX = static_cast<float>(game->current(side).x);
if (!ss.initialized || ss.seq != seq) {
ss.initialized = true;
ss.seq = seq;
ss.visualX = targetX;
// Trigger a short spawn fade so the newly spawned piece visually
// fades into the first visible row (like classic mode).
SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade;
sf.active = true;
sf.startTick = nowTicks;
sf.durationMs = 200.0f;
sf.seq = seq;
sf.piece = game->current(side);
sf.spawnY = sf.piece.y;
sf.tileSize = finalBlockSize;
// Note: during the spawn fade we draw the live piece each frame.
// If the piece is still above the visible grid, we temporarily pin
// it so the topmost filled cell appears at row 0 (no spawn delay),
// while still applying smoothing offsets so it starts moving
// immediately.
sf.targetX = 0.0f;
sf.targetY = 0.0f;
} else {
// Reuse exact horizontal smoothing from single-player
constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor);
}
offsetX = (ss.visualX - targetX) * finalBlockSize;
// Reuse exact single-player fall offset computation (per-side getters)
double gravityMs = game->getGravityMs();
if (gravityMs > 0.0) {
double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs;
double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs);
float progress = static_cast<float>(accumulator / effectiveMs);
progress = std::clamp(progress, 0.0f, 1.0f);
offsetY = progress * finalBlockSize;
// Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player)
const auto& boardRef = game->boardRef();
const CoopGame::Piece& piece = game->current(side);
float maxAllowed = finalBlockSize;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
int gx = piece.x + cx;
int gy = piece.y + cy;
if (gx < 0 || gx >= CoopGame::COLS) continue;
int testY = gy + 1;
int emptyRows = 0;
if (testY < 0) {
emptyRows -= testY;
testY = 0;
}
while (testY >= 0 && testY < CoopGame::ROWS) {
if (boardRef[testY * CoopGame::COLS + gx].occupied) break;
++emptyRows;
++testY;
}
float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
maxAllowed = std::min(maxAllowed, cellLimit);
}
}
offsetY = std::min(offsetY, maxAllowed);
}
} else {
ss.initialized = true;
ss.seq = game->currentPieceSequence(side);
ss.visualX = static_cast<float>(game->current(side).x);
}
if (Settings::instance().isDebugEnabled()) {
float dbg_targetX = static_cast<float>(game->current(side).x);
double gMsDbg = game->getGravityMs();
double accDbg = game->getFallAccumulator(side);
int softDbg = game->isSoftDropping(side) ? 1 : 0;
/*
SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
(side == CoopGame::PlayerSide::Left) ? "L" : "R",
(unsigned long long)ss.seq,
ss.visualX,
dbg_targetX,
offsetX,
offsetY,
gMsDbg,
accDbg,
softDbg
);
*/
}
return std::pair<float, float>{ offsetX, offsetY };
};
auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair<float, float>& offsets) {
if (!sf.active) return;
// If the piece has already changed, stop the fade.
const uint64_t currentSeq = game->currentPieceSequence(side);
if (sf.seq != currentSeq) {
sf.active = false;
return;
}
const CoopGame::Piece& livePiece = game->current(side);
float elapsed = static_cast<float>(nowTicks - sf.startTick);
float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha);
int minCy = 4;
int maxCy = -1;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
minCy = std::min(minCy, cy);
maxCy = std::max(maxCy, cy);
}
}
if (minCy == 4) {
minCy = 0;
}
if (maxCy < 0) {
maxCy = 0;
}
// Pin only when *no* filled cell is visible yet. Using maxCy avoids pinning
// cases like vertical I where some blocks are already visible at spawn.
const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0;
const float baseX = cellX(livePiece.x) + offsets.first;
float baseY = 0.0f;
if (pinToFirstVisibleRow) {
// Keep the piece visible (topmost filled cell at row 0), but also
// incorporate real y-step progression so the fall accumulator wrapping
// doesn't produce a one-row snap.
const int dySteps = livePiece.y - sf.spawnY;
baseY = (gridY - static_cast<float>(minCy) * sf.tileSize)
+ static_cast<float>(dySteps) * sf.tileSize
+ offsets.second;
} else {
baseY = gridY + static_cast<float>(livePiece.y) * sf.tileSize + offsets.second;
}
// Draw the live piece (either pinned-to-row0 or at its real position).
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
int pyIdx = livePiece.y + cy;
if (!pinToFirstVisibleRow && pyIdx < 0) continue;
float px = baseX + static_cast<float>(cx) * sf.tileSize;
float py = baseY + static_cast<float>(cy) * sf.tileSize;
drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type);
}
}
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
// End fade after duration, but never stop while we are pinning (otherwise
// I can briefly disappear until it becomes visible in the real grid).
if (t >= 1.0f && !pinToFirstVisibleRow) {
sf.active = false;
}
};
auto drawPiece = [&](const CoopGame::Piece& p, const std::pair<float, float>& offsets, bool isGhost) {
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(p, cx, cy)) continue;
int pxIdx = p.x + cx;
int pyIdx = p.y + cy;
if (pyIdx < 0) continue; // don't draw parts above the visible grid
float px = cellX(pxIdx) + offsets.first;
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
if (isGhost) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20);
SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
SDL_RenderFillRect(renderer, &rect);
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30);
SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
SDL_RenderRect(renderer, &border);
} else {
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
}
}
}
};
const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth);
const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth);
// Draw transient spawn fades (if active)
drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left, leftOffsets);
drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right, rightOffsets);
// Draw classic-style ghost pieces (landing position), grid-aligned.
// This intentionally does NOT use smoothing offsets.
auto computeGhostPiece = [&](CoopGame::PlayerSide side) {
CoopGame::Piece ghostPiece = game->current(side);
const auto& boardRef = game->boardRef();
while (true) {
CoopGame::Piece testPiece = ghostPiece;
testPiece.y++;
bool collision = false;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(testPiece, cx, cy)) continue;
int gx = testPiece.x + cx;
int gy = testPiece.y + cy;
if (gy >= CoopGame::ROWS || gx < 0 || gx >= CoopGame::COLS ||
(gy >= 0 && boardRef[gy * CoopGame::COLS + gx].occupied)) {
collision = true;
break;
}
}
if (collision) break;
}
if (collision) break;
ghostPiece = testPiece;
}
return ghostPiece;
};
const std::pair<float, float> ghostOffsets{0.0f, 0.0f};
drawPiece(computeGhostPiece(CoopGame::PlayerSide::Left), ghostOffsets, true);
drawPiece(computeGhostPiece(CoopGame::PlayerSide::Right), ghostOffsets, true);
// If a spawn fade is active for a side and matches the current piece
// sequence, only draw the fade visual and skip the regular piece draw
// to avoid a double-draw that appears as a jump when falling starts.
if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) {
drawPiece(game->current(CoopGame::PlayerSide::Left), leftOffsets, false);
}
if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) {
drawPiece(game->current(CoopGame::PlayerSide::Right), rightOffsets, false);
}
// Draw line clearing effects above pieces (matches single-player)
if (lineEffect && lineEffect->isActive()) {
lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), static_cast<int>(COOP_GAP_PX), 10);
}
// Render the SYNC divider last so it stays visible above effects/blocks.
s_syncLine.Render(renderer);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
// Next panels (two)
const float nextPanelPad = 12.0f;
const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f;
const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
float nextLeftX = gridX + finalBlockSize;
float nextRightX = gridX + HALF_W + COOP_GAP_PX + (HALF_W - finalBlockSize - nextPanelW);
float nextY = contentStartY + contentOffsetY;
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
if (nextPanelTex) {
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel);
} else {
drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
}
// Center piece inside panel
int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
minCx = std::min(minCx, cx);
minCy = std::min(minCy, cy);
maxCx = std::max(maxCx, cx);
maxCy = std::max(maxCy, cy);
}
}
if (maxCx >= minCx && maxCy >= minCy) {
float tile = finalBlockSize * 0.8f;
float pieceW = (maxCx - minCx + 1) * tile;
float pieceH = (maxCy - minCy + 1) * tile;
float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile;
float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
float px = startX + cx * tile;
float py = startY + cy * tile;
drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type);
}
}
}
};
drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left));
drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right));
// Per-player scoreboards (left and right)
auto drawPlayerScoreboard = [&](CoopGame::PlayerSide side, float columnLeftX, float columnRightX, const char* title) {
const SDL_Color labelColor{255, 220, 0, 255};
const SDL_Color valueColor{255, 255, 255, 255};
const SDL_Color nextColor{80, 255, 120, 255};
// Match classic vertical placement feel
const float contentTopOffset = 0.0f;
const float contentBottomOffset = 290.0f;
const float contentPad = 36.0f;
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
const float statsPanelPadLeft = 40.0f;
const float statsPanelPadRight = 34.0f;
const float statsPanelPadY = 28.0f;
const float textX = columnLeftX + statsPanelPadLeft;
char scoreStr[32];
std::snprintf(scoreStr, sizeof(scoreStr), "%d", game->score(side));
char linesStr[16];
std::snprintf(linesStr, sizeof(linesStr), "%03d", game->lines(side));
char levelStr[16];
std::snprintf(levelStr, sizeof(levelStr), "%02d", game->level(side));
// Next level progression (per-player lines)
int startLv = game->startLevelBase();
int linesDone = game->lines(side);
int firstThreshold = (startLv + 1) * 10;
int nextThreshold = 0;
if (linesDone < firstThreshold) {
nextThreshold = firstThreshold;
} else {
int blocksPast = linesDone - firstThreshold;
nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
}
int linesForNext = std::max(0, nextThreshold - linesDone);
char nextStr[32];
std::snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
// Time display (shared session time)
int totalSecs = game->elapsed(side);
int mins = totalSecs / 60;
int secs = totalSecs % 60;
char timeStr[16];
std::snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
struct StatLine {
const char* text;
float offsetY;
float scale;
SDL_Color color;
};
// Keep offsets aligned with classic spacing
std::vector<StatLine> statLines;
statLines.reserve(12);
statLines.push_back({title, 0.0f, 0.95f, SDL_Color{200, 220, 235, 220}});
statLines.push_back({"SCORE", 30.0f, 1.0f, labelColor});
statLines.push_back({scoreStr, 55.0f, 0.9f, valueColor});
statLines.push_back({"LINES", 100.0f, 1.0f, labelColor});
statLines.push_back({linesStr, 125.0f, 0.9f, valueColor});
statLines.push_back({"LEVEL", 170.0f, 1.0f, labelColor});
statLines.push_back({levelStr, 195.0f, 0.9f, valueColor});
statLines.push_back({"NEXT LVL", 230.0f, 1.0f, labelColor});
statLines.push_back({nextStr, 255.0f, 0.9f, nextColor});
statLines.push_back({"TIME", 295.0f, 1.0f, labelColor});
statLines.push_back({timeStr, 320.0f, 0.9f, valueColor});
// Size the panel like classic: measure the text block and fit the background.
float statsContentTop = std::numeric_limits<float>::max();
float statsContentBottom = std::numeric_limits<float>::lowest();
float statsContentMaxWidth = 0.0f;
for (const auto& line : statLines) {
int textW = 0;
int textH = 0;
pixelFont->measure(line.text, line.scale, textW, textH);
float y = baseY + line.offsetY;
statsContentTop = std::min(statsContentTop, y);
statsContentBottom = std::max(statsContentBottom, y + static_cast<float>(textH));
statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast<float>(textW));
}
float panelW = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight;
float panelH = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f;
float panelY = statsContentTop - statsPanelPadY;
// Left player is left-aligned in its column; right player is right-aligned.
float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX;
SDL_FRect panelBg{ panelX, panelY, panelW, panelH };
if (scorePanelTex) {
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &panelBg);
} else {
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205);
SDL_RenderFillRect(renderer, &panelBg);
}
float textDrawX = panelX + statsPanelPadLeft;
for (const auto& line : statLines) {
pixelFont->draw(renderer, textDrawX, baseY + line.offsetY, line.text, line.scale, line.color);
}
};
// Nudge panels toward the window edges for tighter symmetry.
const float scorePanelEdgeNudge = 20.0f;
const float leftColumnLeftX = statsX - scorePanelEdgeNudge;
const float leftColumnRightX = leftColumnLeftX + statsW;
const float rightColumnLeftX = rightPanelX;
const float rightColumnRightX = rightColumnLeftX + statsW + scorePanelEdgeNudge;
drawPlayerScoreboard(CoopGame::PlayerSide::Left, leftColumnLeftX, leftColumnRightX, "PLAYER 1");
drawPlayerScoreboard(CoopGame::PlayerSide::Right, rightColumnLeftX, rightColumnRightX, "PLAYER 2");
// Combined score summary centered under the grid
{
int leftScore = game->score(CoopGame::PlayerSide::Left);
int rightScore = game->score(CoopGame::PlayerSide::Right);
int sumScore = leftScore + rightScore;
char sumLabel[64];
char sumValue[64];
std::snprintf(sumLabel, sizeof(sumLabel), "SCORE %d + SCORE %d =", leftScore, rightScore);
std::snprintf(sumValue, sizeof(sumValue), "%d", sumScore);
// Draw label smaller and value larger
float labelScale = 0.9f;
float valueScale = 1.6f;
SDL_Color labelColor = {200, 220, 235, 220};
SDL_Color valueColor = {255, 230, 130, 255};
// Position: centered beneath the grid
float centerX = gridX + GRID_W * 0.5f;
int lw=0, lh=0; pixelFont->measure(sumLabel, labelScale, lw, lh);
int vw=0, vh=0; pixelFont->measure(sumValue, valueScale, vw, vh);
float labelX = centerX - static_cast<float>(lw) * 0.5f;
float valueX = centerX - static_cast<float>(vw) * 0.5f;
float belowY = gridY + GRID_H + 14.0f; // small gap below grid
pixelFont->draw(renderer, labelX, belowY, sumLabel, labelScale, labelColor);
pixelFont->draw(renderer, valueX, belowY + 22.0f, sumValue, valueScale, valueColor);
}
}
void GameRenderer::renderExitPopup( void GameRenderer::renderExitPopup(
SDL_Renderer* renderer, SDL_Renderer* renderer,
FontAtlas* pixelFont, FontAtlas* pixelFont,

View File

@ -3,6 +3,7 @@
#include <vector> #include <vector>
#include <string> #include <string>
#include "../../gameplay/core/Game.h" #include "../../gameplay/core/Game.h"
#include "../../gameplay/coop/CoopGame.h"
// Forward declarations // Forward declarations
class FontAtlas; class FontAtlas;
@ -61,6 +62,24 @@ public:
int selectedButton 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 // 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 // calling from non-member helper functions (e.g. visual effects) that cannot
// access private class members. // access private class members.

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

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

View File

@ -232,6 +232,6 @@ void UIRenderer::drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, floa
// Instructions // Instructions
font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255}); 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}); font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
} }

View File

@ -38,7 +38,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
{"ESC", "Back / cancel current popup"}, {"ESC", "Back / cancel current popup"},
{"F11 or ALT+ENTER", "Toggle fullscreen"}, {"F11 or ALT+ENTER", "Toggle fullscreen"},
{"M", "Mute or unmute music"}, {"M", "Mute or unmute music"},
{"S", "Toggle sound effects"} {"K", "Toggle sound effects"}
}}; }};
const std::array<ShortcutEntry, 2> menuShortcuts{{ const std::array<ShortcutEntry, 2> menuShortcuts{{

View File

@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
namespace coopnet {
// 8-bit input mask carried in NetSession::InputFrame.
// Keep in sync across capture/apply on both peers.
enum Buttons : uint8_t {
MoveLeft = 1u << 0,
MoveRight = 1u << 1,
SoftDrop = 1u << 2,
RotCW = 1u << 3,
RotCCW = 1u << 4,
HardDrop = 1u << 5,
Hold = 1u << 6,
};
inline bool has(uint8_t mask, Buttons b) {
return (mask & static_cast<uint8_t>(b)) != 0;
}
}

324
src/network/NetSession.cpp Normal file
View File

@ -0,0 +1,324 @@
#include "NetSession.h"
#include <enet/enet.h>
#include <SDL3/SDL.h>
#include <cstring>
namespace {
constexpr uint8_t kChannelReliable = 0;
static bool netLogVerboseEnabled() {
// Set environment variable / hint: SPACETRIS_NET_LOG=1
const char* v = SDL_GetHint("SPACETRIS_NET_LOG");
return v && v[0] == '1';
}
template <typename T>
static void append(std::vector<uint8_t>& out, const T& value) {
const uint8_t* p = reinterpret_cast<const uint8_t*>(&value);
out.insert(out.end(), p, p + sizeof(T));
}
template <typename T>
static bool read(const uint8_t* data, size_t size, size_t& off, T& out) {
if (off + sizeof(T) > size) return false;
std::memcpy(&out, data + off, sizeof(T));
off += sizeof(T);
return true;
}
}
NetSession::NetSession() = default;
NetSession::~NetSession() {
shutdown();
}
bool NetSession::ensureEnetInitialized() {
static bool s_inited = false;
if (s_inited) return true;
if (enet_initialize() != 0) {
setError("enet_initialize failed");
m_state = ConnState::Error;
return false;
}
s_inited = true;
return true;
}
void NetSession::setError(const std::string& msg) {
m_lastError = msg;
}
bool NetSession::host(const std::string& bindHost, uint16_t port) {
shutdown();
if (!ensureEnetInitialized()) return false;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] host(bind='%s', port=%u)", bindHost.c_str(), (unsigned)port);
ENetAddress address{};
address.host = ENET_HOST_ANY;
address.port = port;
if (!bindHost.empty() && bindHost != "0.0.0.0") {
if (enet_address_set_host(&address, bindHost.c_str()) != 0) {
setError("enet_address_set_host (bind) failed");
m_state = ConnState::Error;
return false;
}
}
// 1 peer, 2 channels (reserve extra)
m_host = enet_host_create(&address, 1, 2, 0, 0);
if (!m_host) {
setError("enet_host_create (host) failed");
m_state = ConnState::Error;
return false;
}
m_mode = Mode::Host;
m_state = ConnState::Connecting;
return true;
}
bool NetSession::join(const std::string& hostNameOrIp, uint16_t port) {
shutdown();
if (!ensureEnetInitialized()) return false;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] join(remote='%s', port=%u)", hostNameOrIp.c_str(), (unsigned)port);
m_host = enet_host_create(nullptr, 1, 2, 0, 0);
if (!m_host) {
setError("enet_host_create (client) failed");
m_state = ConnState::Error;
return false;
}
ENetAddress address{};
if (enet_address_set_host(&address, hostNameOrIp.c_str()) != 0) {
setError("enet_address_set_host failed");
m_state = ConnState::Error;
return false;
}
address.port = port;
m_peer = enet_host_connect(m_host, &address, 2, 0);
if (!m_peer) {
setError("enet_host_connect failed");
m_state = ConnState::Error;
return false;
}
m_mode = Mode::Client;
m_state = ConnState::Connecting;
return true;
}
void NetSession::shutdown() {
if (m_host || m_peer) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] shutdown(mode=%d state=%d)", (int)m_mode, (int)m_state);
}
m_remoteInputs.clear();
m_remoteHashes.clear();
m_receivedHandshake.reset();
m_inputsSent = 0;
m_inputsReceived = 0;
m_hashesSent = 0;
m_hashesReceived = 0;
m_handshakesSent = 0;
m_handshakesReceived = 0;
m_lastRecvInputTick = 0xFFFFFFFFu;
m_lastRecvHashTick = 0xFFFFFFFFu;
m_lastStatsLogMs = 0;
if (m_peer) {
enet_peer_disconnect(m_peer, 0);
m_peer = nullptr;
}
if (m_host) {
enet_host_destroy(m_host);
m_host = nullptr;
}
m_mode = Mode::None;
m_state = ConnState::Disconnected;
m_lastError.clear();
}
void NetSession::poll(uint32_t timeoutMs) {
if (!m_host) return;
ENetEvent event{};
while (enet_host_service(m_host, &event, static_cast<enet_uint32>(timeoutMs)) > 0) {
switch (event.type) {
case ENET_EVENT_TYPE_CONNECT:
m_peer = event.peer;
m_state = ConnState::Connected;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] connected (mode=%d)", (int)m_mode);
break;
case ENET_EVENT_TYPE_RECEIVE:
if (event.packet) {
handlePacket(event.packet->data, event.packet->dataLength);
enet_packet_destroy(event.packet);
}
break;
case ENET_EVENT_TYPE_DISCONNECT:
m_peer = nullptr;
m_state = ConnState::Disconnected;
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET] disconnected");
break;
case ENET_EVENT_TYPE_NONE:
default:
break;
}
// After first event, do non-blocking passes.
timeoutMs = 0;
}
// Rate-limited stats log (opt-in)
if (netLogVerboseEnabled()) {
const uint32_t nowMs = SDL_GetTicks();
if (m_lastStatsLogMs == 0) m_lastStatsLogMs = nowMs;
if (nowMs - m_lastStatsLogMs >= 1000u) {
m_lastStatsLogMs = nowMs;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET] stats: sent(in=%u hash=%u hs=%u) recv(in=%u hash=%u hs=%u) lastRecv(inTick=%u hashTick=%u) state=%d",
m_inputsSent,
m_hashesSent,
m_handshakesSent,
m_inputsReceived,
m_hashesReceived,
m_handshakesReceived,
m_lastRecvInputTick,
m_lastRecvHashTick,
(int)m_state);
}
}
}
bool NetSession::sendBytesReliable(const void* data, size_t size) {
if (!m_peer) return false;
ENetPacket* packet = enet_packet_create(data, size, ENET_PACKET_FLAG_RELIABLE);
if (!packet) return false;
if (enet_peer_send(m_peer, kChannelReliable, packet) != 0) {
enet_packet_destroy(packet);
return false;
}
// Let the caller decide flush cadence; but for tiny control packets, flushing is cheap.
enet_host_flush(m_host);
return true;
}
bool NetSession::sendHandshake(const Handshake& hs) {
if (m_mode != Mode::Host) return false;
m_handshakesSent++;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
std::vector<uint8_t> buf;
buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t));
buf.push_back(static_cast<uint8_t>(MsgType::Handshake));
append(buf, hs.rngSeed);
append(buf, hs.startTick);
append(buf, hs.startLevel);
return sendBytesReliable(buf.data(), buf.size());
}
std::optional<NetSession::Handshake> NetSession::takeReceivedHandshake() {
auto out = m_receivedHandshake;
m_receivedHandshake.reset();
return out;
}
bool NetSession::sendLocalInput(uint32_t tick, uint8_t buttons) {
m_inputsSent++;
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
}
std::vector<uint8_t> buf;
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t));
buf.push_back(static_cast<uint8_t>(MsgType::Input));
append(buf, tick);
append(buf, buttons);
return sendBytesReliable(buf.data(), buf.size());
}
std::optional<uint8_t> NetSession::getRemoteButtons(uint32_t tick) const {
auto it = m_remoteInputs.find(tick);
if (it == m_remoteInputs.end()) return std::nullopt;
return it->second;
}
bool NetSession::sendStateHash(uint32_t tick, uint64_t hash) {
m_hashesSent++;
if (netLogVerboseEnabled()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
}
std::vector<uint8_t> buf;
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t));
buf.push_back(static_cast<uint8_t>(MsgType::Hash));
append(buf, tick);
append(buf, hash);
return sendBytesReliable(buf.data(), buf.size());
}
std::optional<uint64_t> NetSession::takeRemoteHash(uint32_t tick) {
auto it = m_remoteHashes.find(tick);
if (it == m_remoteHashes.end()) return std::nullopt;
uint64_t v = it->second;
m_remoteHashes.erase(it);
return v;
}
void NetSession::handlePacket(const uint8_t* data, size_t size) {
if (!data || size < 1) return;
size_t off = 0;
uint8_t typeByte = 0;
if (!read(data, size, off, typeByte)) return;
MsgType t = static_cast<MsgType>(typeByte);
switch (t) {
case MsgType::Handshake: {
Handshake hs{};
if (!read(data, size, off, hs.rngSeed)) return;
if (!read(data, size, off, hs.startTick)) return;
if (!read(data, size, off, hs.startLevel)) return;
m_receivedHandshake = hs;
m_handshakesReceived++;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
break;
}
case MsgType::Input: {
uint32_t tick = 0;
uint8_t buttons = 0;
if (!read(data, size, off, tick)) return;
if (!read(data, size, off, buttons)) return;
m_remoteInputs[tick] = buttons;
m_inputsReceived++;
m_lastRecvInputTick = tick;
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
}
break;
}
case MsgType::Hash: {
uint32_t tick = 0;
uint64_t hash = 0;
if (!read(data, size, off, tick)) return;
if (!read(data, size, off, hash)) return;
m_remoteHashes[tick] = hash;
m_hashesReceived++;
m_lastRecvHashTick = tick;
if (netLogVerboseEnabled()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
}
break;
}
default:
break;
}
}

118
src/network/NetSession.h Normal file
View File

@ -0,0 +1,118 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
struct _ENetHost;
struct _ENetPeer;
// Lockstep networking session for COOPERATE (network) mode.
//
// Design goals:
// - Non-blocking polling (caller drives poll from the main loop)
// - Reliable, ordered delivery for inputs and control messages
// - Host provides seed + start tick (handshake)
// - Only inputs/state hashes are exchanged (no board sync)
class NetSession {
public:
enum class Mode {
None,
Host,
Client,
};
enum class ConnState {
Disconnected,
Connecting,
Connected,
Error,
};
struct Handshake {
uint32_t rngSeed = 0;
uint32_t startTick = 0;
uint8_t startLevel = 0;
};
struct InputFrame {
uint32_t tick = 0;
uint8_t buttons = 0;
};
NetSession();
~NetSession();
NetSession(const NetSession&) = delete;
NetSession& operator=(const NetSession&) = delete;
// If bindHost is empty or "0.0.0.0", binds to ENET_HOST_ANY.
bool host(const std::string& bindHost, uint16_t port);
bool join(const std::string& hostNameOrIp, uint16_t port);
void shutdown();
void poll(uint32_t timeoutMs = 0);
Mode mode() const { return m_mode; }
ConnState state() const { return m_state; }
bool isConnected() const { return m_state == ConnState::Connected; }
// Host-only: send handshake once the peer connects.
bool sendHandshake(const Handshake& hs);
// Client-only: becomes available once received from host.
std::optional<Handshake> takeReceivedHandshake();
// Input exchange --------------------------------------------------------
// Send local input for a given simulation tick.
bool sendLocalInput(uint32_t tick, uint8_t buttons);
// Returns the last received remote input for a tick (if any).
std::optional<uint8_t> getRemoteButtons(uint32_t tick) const;
// Hash exchange (for desync detection) ---------------------------------
bool sendStateHash(uint32_t tick, uint64_t hash);
std::optional<uint64_t> takeRemoteHash(uint32_t tick);
// Diagnostics
std::string lastError() const { return m_lastError; }
private:
enum class MsgType : uint8_t {
Handshake = 1,
Input = 2,
Hash = 3,
};
bool ensureEnetInitialized();
void setError(const std::string& msg);
bool sendBytesReliable(const void* data, size_t size);
void handlePacket(const uint8_t* data, size_t size);
Mode m_mode = Mode::None;
ConnState m_state = ConnState::Disconnected;
_ENetHost* m_host = nullptr;
_ENetPeer* m_peer = nullptr;
std::string m_lastError;
std::optional<Handshake> m_receivedHandshake;
std::unordered_map<uint32_t, uint8_t> m_remoteInputs;
std::unordered_map<uint32_t, uint64_t> m_remoteHashes;
// Debug logging (rate-limited)
uint32_t m_inputsSent = 0;
uint32_t m_inputsReceived = 0;
uint32_t m_hashesSent = 0;
uint32_t m_hashesReceived = 0;
uint32_t m_handshakesSent = 0;
uint32_t m_handshakesReceived = 0;
uint32_t m_lastRecvInputTick = 0xFFFFFFFFu;
uint32_t m_lastRecvHashTick = 0xFFFFFFFFu;
uint32_t m_lastStatsLogMs = 0;
};

View 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

View 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

View File

@ -1,20 +1,18 @@
// Scores.cpp - Implementation of ScoreManager with Firebase Sync // Scores.cpp - Implementation of ScoreManager
#include "Scores.h" #include "Scores.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <algorithm> #include <algorithm>
#include <cpr/cpr.h> #include "../network/supabase_client.h"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <iostream> #include <iostream>
#include <thread> #include <thread>
#include <ctime> #include <ctime>
#include <filesystem>
using json = nlohmann::json; 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) {} ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {}
std::string ScoreManager::filePath() const { std::string ScoreManager::filePath() const {
@ -27,48 +25,19 @@ std::string ScoreManager::filePath() const {
void ScoreManager::load() { void ScoreManager::load() {
scores.clear(); scores.clear();
// Try to load from Firebase first // Try to load from Supabase first
try { try {
cpr::Response r = cpr::Get(cpr::Url{FIREBASE_URL}, cpr::Timeout{2000}); // 2s timeout // Request only 10 records from Supabase to keep payload small
if (r.status_code == 200 && !r.text.empty() && r.text != "null") { auto fetched = supabase::FetchHighscores("", 10);
auto j = json::parse(r.text); if (!fetched.empty()) {
scores = fetched;
// 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
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;}); std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
if (scores.size() > maxEntries) scores.resize(maxEntries); if (scores.size() > maxEntries) scores.resize(maxEntries);
// Save to local cache
save(); save();
return; return;
} }
} catch (...) { } catch (...) {
// Ignore network errors and fall back to local file std::cerr << "Failed to load from Supabase, falling back to local file." << std::endl;
std::cerr << "Failed to load from Firebase, falling back to local file." << std::endl;
} }
// Fallback to local file // Fallback to local file
@ -86,11 +55,32 @@ void ScoreManager::load() {
ScoreEntry e; ScoreEntry e;
iss >> e.score >> e.lines >> e.level >> e.timeSec; iss >> e.score >> e.lines >> e.level >> e.timeSec;
if (iss) { 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::string remaining;
std::getline(iss, remaining); std::getline(iss, remaining);
if (!remaining.empty() && remaining[0] == ' ') { if (!remaining.empty() && remaining[0] == ' ') remaining = remaining.substr(1);
e.name = remaining.substr(1); // Remove leading space 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); scores.push_back(e);
} }
@ -108,42 +98,28 @@ void ScoreManager::load() {
void ScoreManager::save() const { void ScoreManager::save() const {
std::ofstream f(filePath(), std::ios::trunc); std::ofstream f(filePath(), std::ios::trunc);
for (auto &e : scores) { 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 // 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;}); std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
if (scores.size()>maxEntries) scores.resize(maxEntries); if (scores.size()>maxEntries) scores.resize(maxEntries);
save(); save();
// Submit to Supabase asynchronously
// Submit to Firebase ScoreEntry se{score, lines, level, timeSec, name, gameType};
// Run in a detached thread to avoid blocking the UI? supabase::SubmitHighscoreAsync(se);
// 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();
} }
bool ScoreManager::isHighScore(int score) const { bool ScoreManager::isHighScore(int score) const {
@ -151,19 +127,28 @@ bool ScoreManager::isHighScore(int score) const {
return score > scores.back().score; 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() { void ScoreManager::createSampleScores() {
scores = { scores = {
{159840, 189, 14, 972, "GREGOR"}, {159840, 189, 14, 972.0, "GREGOR"},
{156340, 132, 12, 714, "GREGOR"}, {156340, 132, 12, 714.0, "GREGOR"},
{155219, 125, 12, 696, "GREGOR"}, {155219, 125, 12, 696.0, "GREGOR"},
{141823, 123, 10, 710, "GREGOR"}, {141823, 123, 10, 710.0, "GREGOR"},
{140079, 71, 11, 410, "GREGOR"}, {140079, 71, 11, 410.0, "GREGOR"},
{116012, 121, 10, 619, "GREGOR"}, {116012, 121, 10, 619.0, "GREGOR"},
{112643, 137, 13, 689, "GREGOR"}, {112643, 137, 13, 689.0, "GREGOR"},
{99190, 61, 10, 378, "GREGOR"}, {99190, 61, 10, 378.0, "GREGOR"},
{93648, 107, 10, 629, "GREGOR"}, {93648, 107, 10, 629.0, "GREGOR"},
{89041, 115, 10, 618, "GREGOR"}, {89041, 115, 10, 618.0, "GREGOR"},
{88600, 55, 9, 354, "GREGOR"}, {88600, 55, 9, 354.0, "GREGOR"},
{86346, 141, 13, 723, "GREGOR"} {86346, 141, 13, 723.0, "GREGOR"}
}; };
} }

View File

@ -3,14 +3,18 @@
#include <vector> #include <vector>
#include <string> #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 { class ScoreManager {
public: public:
explicit ScoreManager(size_t maxScores = 12); explicit ScoreManager(size_t maxScores = 12);
void load(); void load();
void save() const; 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; bool isHighScore(int score) const;
const std::vector<ScoreEntry>& all() const { return scores; } const std::vector<ScoreEntry>& all() const { return scores; }
private: private:

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,12 @@
#pragma once #pragma once
#include "State.h" #include "State.h"
#include <cstdint>
#include <memory>
#include <string>
class NetSession;
class MenuState : public State { class MenuState : public State {
public: public:
MenuState(StateContext& ctx); MenuState(StateContext& ctx);
@ -19,9 +25,13 @@ public:
void showHelpPanel(bool show); void showHelpPanel(bool show);
// Show or hide the inline ABOUT panel (menu-style) // Show or hide the inline ABOUT panel (menu-style)
void showAboutPanel(bool show); void showAboutPanel(bool show);
// Show or hide the inline COOPERATE setup panel (2P vs AI).
// If `resumeMusic` is false when hiding, the menu music will not be restarted.
void showCoopSetupPanel(bool show, bool resumeMusic = true);
private: private:
int selectedButton = 0; // 0 = PLAY, 1 = 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) // Button icons (optional - will use text if nullptr)
SDL_Texture* playIcon = nullptr; SDL_Texture* playIcon = nullptr;
@ -94,4 +104,37 @@ private:
double aboutTransition = 0.0; // 0..1 double aboutTransition = 0.0; // 0..1
double aboutTransitionDurationMs = 360.0; double aboutTransitionDurationMs = 360.0;
int aboutDirection = 1; // 1 show, -1 hide int aboutDirection = 1; // 1 show, -1 hide
// Coop setup panel (inline HUD like Exit/Help)
bool coopSetupVisible = false;
bool coopSetupAnimating = false;
double coopSetupTransition = 0.0; // 0..1
double coopSetupTransitionDurationMs = 320.0;
int coopSetupDirection = 1; // 1 show, -1 hide
// 0 = Local co-op (2 players), 1 = AI partner, 2 = 2 player (network)
int coopSetupSelected = 0;
enum class CoopSetupStep {
ChoosePartner,
NetworkChooseRole,
NetworkEnterAddress,
NetworkWaiting,
};
CoopSetupStep coopSetupStep = CoopSetupStep::ChoosePartner;
// Network sub-flow state (only used when coopSetupSelected == 2)
int coopNetworkRoleSelected = 0; // 0 = host, 1 = join
std::string coopNetworkBindAddress = "0.0.0.0";
std::string coopNetworkJoinAddress = "127.0.0.1";
uint16_t coopNetworkPort = 7777;
bool coopNetworkHandshakeSent = false;
std::string coopNetworkStatusText;
std::unique_ptr<NetSession> coopNetworkSession;
SDL_FRect coopSetupBtnRects[3]{};
bool coopSetupRectsValid = false;
// Optional cooperative info image shown when coop setup panel is active
SDL_Texture* coopInfoTexture = nullptr;
int coopInfoTexW = 0;
int coopInfoTexH = 0;
}; };

View File

@ -1,13 +1,16 @@
#include "PlayingState.h" #include "PlayingState.h"
#include "../core/state/StateManager.h" #include "../core/state/StateManager.h"
#include "../gameplay/core/Game.h" #include "../gameplay/core/Game.h"
#include "../gameplay/coop/CoopGame.h"
#include "../gameplay/effects/LineEffect.h" #include "../gameplay/effects/LineEffect.h"
#include "../persistence/Scores.h" #include "../persistence/Scores.h"
#include "../audio/Audio.h" #include "../audio/Audio.h"
#include "../audio/SoundEffect.h" #include "../audio/SoundEffect.h"
#include "../graphics/Font.h"
#include "../graphics/renderers/GameRenderer.h" #include "../graphics/renderers/GameRenderer.h"
#include "../core/Settings.h" #include "../core/Settings.h"
#include "../core/Config.h" #include "../core/Config.h"
#include "../network/CoopNetButtons.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
// File-scope transport/spawn detection state // File-scope transport/spawn detection state
@ -18,12 +21,23 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
void PlayingState::onEnter() { void PlayingState::onEnter() {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state"); 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) {
if (ctx.game->getMode() == GameMode::Endless) { if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
if (ctx.startLevelSelection) { if (ctx.startLevelSelection) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
ctx.game->reset(*ctx.startLevelSelection); const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession;
// For network co-op, MenuState already performed a deterministic reset using the negotiated seed.
// Re-resetting here would overwrite it (and will desync).
if (!coopNetActive) {
ctx.game->reset(*ctx.startLevelSelection);
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
ctx.coopGame->reset(*ctx.startLevelSelection);
}
} else {
ctx.game->setPaused(false);
}
} }
} else { } else {
// Challenge run is prepared before entering; ensure gameplay is unpaused // Challenge run is prepared before entering; ensure gameplay is unpaused
@ -42,127 +56,248 @@ void PlayingState::onExit() {
SDL_DestroyTexture(m_renderTarget); SDL_DestroyTexture(m_renderTarget);
m_renderTarget = nullptr; m_renderTarget = nullptr;
} }
// If we are leaving gameplay during network co-op, tear down the session so
// hosting/joining again works without restarting the app.
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = false;
ctx.coopNetStalled = false;
ctx.coopNetDesyncDetected = false;
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
} }
void PlayingState::handleEvent(const SDL_Event& e) { void PlayingState::handleEvent(const SDL_Event& e) {
if (!ctx.game) return;
// If a transport animation is active, ignore gameplay input entirely. // If a transport animation is active, ignore gameplay input entirely.
if (GameRenderer::isTransportActive()) { if (GameRenderer::isTransportActive()) {
return; 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) { const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
if (ctx.exitPopupSelectedButton) {
*ctx.exitPopupSelectedButton = value;
}
};
auto getExitSelection = [&]() -> int { auto setExitSelection = [&](int idx) {
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1; if (ctx.exitPopupSelectedButton) {
}; *ctx.exitPopupSelectedButton = idx;
}
};
auto getExitSelection = [&]() -> int {
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
};
// Pause toggle (P) if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) {
if (e.key.scancode == SDL_SCANCODE_P) { return;
bool paused = ctx.game->isPaused(); }
ctx.game->setPaused(!paused);
// 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; return;
} }
// If exit-confirm popup is visible, handle shortcuts here // Activate selected button with Enter or Space
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) { if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
// Navigate between YES (0) and NO (1) buttons const bool confirmExit = (getExitSelection() == 0);
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) { *ctx.showExitConfirmPopup = false;
setExitSelection(0); if (confirmExit) {
return; // YES - Reset game and return to menu
} if (ctx.startLevelSelection) {
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) { ctx.game->reset(*ctx.startLevelSelection);
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);
} else { } else {
// NO - Just close popup and resume ctx.game->reset(0);
ctx.game->setPaused(false);
} }
return;
}
// Cancel with Esc (same as NO)
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
*ctx.showExitConfirmPopup = false;
ctx.game->setPaused(false); ctx.game->setPaused(false);
setExitSelection(1); if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
return; } else {
// NO - Just close popup and resume
ctx.game->setPaused(false);
} }
// While modal is open, suppress other gameplay keys
return; return;
} }
// Cancel with Esc (same as NO)
// ESC key - open confirmation popup
if (e.key.scancode == SDL_SCANCODE_ESCAPE) { if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
if (ctx.showExitConfirmPopup) { *ctx.showExitConfirmPopup = false;
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.game->setPaused(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; return;
} }
// Tetris controls (only when not paused) // Rotation (still event-based for precise timing)
if (!ctx.game->isPaused()) { if (e.key.scancode == SDL_SCANCODE_UP) {
// Hold / swap current piece (H) // Use user setting to determine whether UP rotates clockwise
if (e.key.scancode == SDL_SCANCODE_H) { bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.game->holdCurrent(); ctx.game->rotate(upIsCW ? 1 : -1);
return; 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) // Hard drop (space)
if (e.key.scancode == SDL_SCANCODE_UP) { if (e.key.scancode == SDL_SCANCODE_SPACE) {
// Use user setting to determine whether UP rotates clockwise SoundEffectManager::instance().playSound("hard_drop", 0.7f);
bool upIsCW = Settings::instance().isUpRotateClockwise(); ctx.game->hardDrop();
ctx.game->rotate(upIsCW ? 1 : -1); return;
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;
}
} }
} }
@ -172,7 +307,21 @@ void PlayingState::handleEvent(const SDL_Event& e) {
void PlayingState::update(double frameMs) { void PlayingState::update(double frameMs) {
if (!ctx.game) return; 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); ctx.game->updateVisualEffects(frameMs);
// If a transport animation is active, pause gameplay updates and ignore inputs // If a transport animation is active, pause gameplay updates and ignore inputs
if (GameRenderer::isTransportActive()) { if (GameRenderer::isTransportActive()) {
@ -204,6 +353,8 @@ void PlayingState::update(double frameMs) {
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
if (!ctx.game) return; if (!ctx.game) return;
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
// Get current window size // Get current window size
int winW = 0, winH = 0; int winW = 0, winH = 0;
SDL_GetRenderOutputSize(renderer, &winW, &winH); 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. // But countdown should definitely NOT show the "PAUSED" overlay.
bool shouldBlur = paused && !countdown && !challengeClearFx; bool shouldBlur = paused && !countdown && !challengeClearFx;
auto renderNetOverlay = [&]() {
if (!coopActive || !ctx.coopNetEnabled || !ctx.pixelFont) return;
if (!ctx.coopNetDesyncDetected && !ctx.coopNetStalled) return;
const char* text = ctx.coopNetDesyncDetected ? "NET: DESYNC" : "NET: STALLED";
SDL_Color textColor = ctx.coopNetDesyncDetected ? SDL_Color{255, 230, 180, 255} : SDL_Color{255, 224, 130, 255};
float scale = 0.75f;
int tw = 0, th = 0;
ctx.pixelFont->measure(text, scale, tw, th);
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const float pad = 8.0f;
const float x = 18.0f;
const float y = 14.0f;
SDL_FRect bg{ x - pad, y - pad, (float)tw + pad * 2.0f, (float)th + pad * 2.0f };
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
SDL_RenderFillRect(renderer, &bg);
ctx.pixelFont->draw(renderer, x, y, text, scale, textColor);
SDL_SetRenderDrawBlendMode(renderer, prevBlend);
};
if (shouldBlur && m_renderTarget) { if (shouldBlur && m_renderTarget) {
// Render game to texture // Render game to texture
SDL_SetRenderTarget(renderer, m_renderTarget); SDL_SetRenderTarget(renderer, m_renderTarget);
@ -244,26 +420,45 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
// Render game content (no overlays) // Render game content (no overlays)
// If a transport effect was requested due to a recent spawn, start it here so // 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. // 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); GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
s_pendingTransport = false; s_pendingTransport = false;
} }
GameRenderer::renderPlayingState( if (coopActive && ctx.coopGame) {
renderer, GameRenderer::renderCoopPlayingState(
ctx.game, renderer,
ctx.pixelFont, ctx.coopGame,
ctx.lineEffect, ctx.pixelFont,
ctx.blocksTex, ctx.lineEffect,
ctx.asteroidsTex, ctx.blocksTex,
ctx.statisticsPanelTex, ctx.statisticsPanelTex,
ctx.scorePanelTex, ctx.scorePanelTex,
ctx.nextPanelTex, ctx.nextPanelTex,
ctx.holdPanelTex, ctx.holdPanelTex,
countdown, paused,
1200.0f, // LOGICAL_W 1200.0f,
1000.0f, // LOGICAL_H 1000.0f,
logicalScale, 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)winW,
(float)winH, (float)winH,
challengeClearFx, challengeClearFx,
@ -272,7 +467,8 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
challengeClearDuration, challengeClearDuration,
countdown ? nullptr : ctx.challengeStoryText, countdown ? nullptr : ctx.challengeStoryText,
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f) countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
); );
}
// Reset to screen // Reset to screen
SDL_SetRenderTarget(renderer, nullptr); SDL_SetRenderTarget(renderer, nullptr);
@ -319,6 +515,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
SDL_SetRenderViewport(renderer, &oldVP); SDL_SetRenderViewport(renderer, &oldVP);
SDL_SetRenderScale(renderer, oldSX, oldSY); SDL_SetRenderScale(renderer, oldSX, oldSY);
// Net overlay (on top of blurred game, under pause/exit overlays)
renderNetOverlay();
// Draw overlays // Draw overlays
if (exitPopup) { if (exitPopup) {
GameRenderer::renderExitPopup( GameRenderer::renderExitPopup(
@ -341,33 +540,57 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
} else { } else {
// Render normally directly to screen // 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); GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
s_pendingTransport = false; s_pendingTransport = false;
} }
GameRenderer::renderPlayingState(
renderer, if (coopActive && ctx.coopGame) {
ctx.game, GameRenderer::renderCoopPlayingState(
ctx.pixelFont, renderer,
ctx.lineEffect, ctx.coopGame,
ctx.blocksTex, ctx.pixelFont,
ctx.asteroidsTex, ctx.lineEffect,
ctx.statisticsPanelTex, ctx.blocksTex,
ctx.scorePanelTex, ctx.statisticsPanelTex,
ctx.nextPanelTex, ctx.scorePanelTex,
ctx.holdPanelTex, ctx.nextPanelTex,
countdown, ctx.holdPanelTex,
1200.0f, paused,
1000.0f, 1200.0f,
logicalScale, 1000.0f,
(float)winW, logicalScale,
(float)winH, (float)winW,
challengeClearFx, (float)winH
challengeClearOrder, );
challengeClearElapsed,
challengeClearDuration, // Net overlay (on top of coop HUD)
countdown ? nullptr : ctx.challengeStoryText, renderNetOverlay();
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f) } 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)
);
}
} }
} }

View File

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

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

@ -0,0 +1,389 @@
// VideoState.cpp
#include "VideoState.h"
#include "../video/VideoPlayer.h"
#include "../audio/Audio.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).
Audio::instance().playSfx(m_audioPcm, m_audioChannels, m_audioRate, 1.0f);
m_audioStarted = true;
}
void VideoState::stopAudio() {
// We currently feed intro audio as an SFX buffer into the mixer.
// It will naturally end; no explicit stop is required.
}
void VideoState::update(double frameMs) {
switch (m_phase) {
case Phase::FadeInFirstFrame: {
m_phaseClockMs += frameMs;
const float t = (FADE_IN_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_IN_MS, 0.0, 1.0)) : 1.0f;
m_blackOverlayAlpha = 1.0f - t;
if (t >= 1.0f) {
m_phase = Phase::Playing;
m_phaseClockMs = 0.0;
if (m_player) {
m_player->start();
}
startAudioIfReady();
}
break;
}
case Phase::Playing: {
startAudioIfReady();
if (m_player) {
m_player->update(frameMs);
if (m_player->isFinished()) {
m_phase = Phase::FadeOutToBlack;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 0.0f;
}
} else {
m_phase = Phase::FadeOutToBlack;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 0.0f;
}
break;
}
case Phase::FadeOutToBlack: {
m_phaseClockMs += frameMs;
const float t = (FADE_OUT_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_OUT_MS, 0.0, 1.0)) : 1.0f;
m_blackOverlayAlpha = t;
if (t >= 1.0f) {
// Switch to MAIN (Menu) with a fade-in from black.
if (ctx.startupFadeAlpha) {
*ctx.startupFadeAlpha = 1.0f;
}
if (ctx.startupFadeActive) {
*ctx.startupFadeActive = true;
}
if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Menu);
}
m_phase = Phase::Done;
}
break;
}
case Phase::Done:
default:
break;
}
}
void VideoState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
(void)logicalScale;
(void)logicalVP;
if (!renderer) return;
int winW = 0, winH = 0;
SDL_GetRenderOutputSize(renderer, &winW, &winH);
// Draw video fullscreen if available.
if (m_player && m_player->isTextureReady()) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
m_player->render(renderer, winW, winH);
} else {
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect r{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &r);
}
// Apply fade overlay (black).
if (m_blackOverlayAlpha > 0.0f) {
const Uint8 a = (Uint8)std::clamp((int)std::lround(m_blackOverlayAlpha * 255.0f), 0, 255);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &full);
}
}
bool VideoState::decodeAudioPcm16Stereo44100(
const std::string& path,
std::vector<int16_t>& outPcm,
int& outRate,
int& outChannels
) {
outPcm.clear();
outRate = 44100;
outChannels = 2;
AVFormatContext* fmt = nullptr;
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) {
return false;
}
if (avformat_find_stream_info(fmt, nullptr) < 0) {
avformat_close_input(&fmt);
return false;
}
int audioStream = -1;
for (unsigned i = 0; i < fmt->nb_streams; ++i) {
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
audioStream = (int)i;
break;
}
}
if (audioStream < 0) {
avformat_close_input(&fmt);
return false;
}
AVCodecParameters* codecpar = fmt->streams[audioStream]->codecpar;
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
if (!codec) {
avformat_close_input(&fmt);
return false;
}
AVCodecContext* dec = avcodec_alloc_context3(codec);
if (!dec) {
avformat_close_input(&fmt);
return false;
}
if (avcodec_parameters_to_context(dec, codecpar) < 0) {
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
if (avcodec_open2(dec, codec, nullptr) < 0) {
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
AVChannelLayout outLayout{};
av_channel_layout_default(&outLayout, 2);
AVChannelLayout inLayout{};
if (av_channel_layout_copy(&inLayout, &dec->ch_layout) < 0 || inLayout.nb_channels <= 0) {
av_channel_layout_uninit(&inLayout);
av_channel_layout_default(&inLayout, 2);
}
SwrContext* swr = nullptr;
if (swr_alloc_set_opts2(
&swr,
&outLayout,
AV_SAMPLE_FMT_S16,
44100,
&inLayout,
dec->sample_fmt,
dec->sample_rate,
0,
nullptr
) < 0) {
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
if (swr_init(swr) < 0) {
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
AVPacket* pkt = av_packet_alloc();
AVFrame* frame = av_frame_alloc();
if (!pkt || !frame) {
if (pkt) av_packet_free(&pkt);
if (frame) av_frame_free(&frame);
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
const int outRateConst = 44100;
const int outCh = 2;
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index != audioStream) {
av_packet_unref(pkt);
continue;
}
if (avcodec_send_packet(dec, pkt) < 0) {
av_packet_unref(pkt);
continue;
}
av_packet_unref(pkt);
while (true) {
const int rr = avcodec_receive_frame(dec, frame);
if (rr == AVERROR(EAGAIN) || rr == AVERROR_EOF) {
break;
}
if (rr < 0) {
break;
}
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
std::vector<uint8_t> outBytes;
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
uint8_t* outData[1] = { outBytes.data() };
const uint8_t** inData = (const uint8_t**)frame->data;
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
if (converted > 0) {
const size_t samplesOut = (size_t)converted * (size_t)outCh;
const int16_t* asS16 = (const int16_t*)outBytes.data();
const size_t oldSize = outPcm.size();
outPcm.resize(oldSize + samplesOut);
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
}
av_frame_unref(frame);
}
}
// Flush decoder
avcodec_send_packet(dec, nullptr);
while (avcodec_receive_frame(dec, frame) >= 0) {
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
std::vector<uint8_t> outBytes;
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
uint8_t* outData[1] = { outBytes.data() };
const uint8_t** inData = (const uint8_t**)frame->data;
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
if (converted > 0) {
const size_t samplesOut = (size_t)converted * (size_t)outCh;
const int16_t* asS16 = (const int16_t*)outBytes.data();
const size_t oldSize = outPcm.size();
outPcm.resize(oldSize + samplesOut);
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
}
av_frame_unref(frame);
}
av_frame_free(&frame);
av_packet_free(&pkt);
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
outRate = outRateConst;
outChannels = outCh;
return !outPcm.empty();
}

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

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

View File

@ -13,7 +13,7 @@ static bool pointInRect(const SDL_FRect& r, float x, float y) {
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h); return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
} }
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) { BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI) {
BottomMenu menu{}; BottomMenu menu{};
auto rects = computeMenuButtonRects(params); auto rects = computeMenuButtonRects(params);
@ -22,12 +22,14 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel); std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false }; menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false }; // Always show a neutral "COOPERATE" label (remove per-mode suffixes)
menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true }; menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true }; menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true }; menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true }; menu.buttons[4] = Button{ BottomMenuItem::Options, rects[4], "OPTIONS", true };
menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", 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; return menu;
} }
@ -62,10 +64,15 @@ void renderBottomMenu(SDL_Renderer* renderer,
if (!b.textOnly) { if (!b.textOnly) {
const bool isPlay = (i == 0); 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 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)) }; 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 // Give Challenge a teal accent to distinguish from Play
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) }; 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)) }; 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_BlendMode prevBlend = SDL_BLENDMODE_NONE;
SDL_GetRenderDrawBlendMode(renderer, &prevBlend); SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul))); 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; const int lastSmall = MENU_BTN_COUNT - 1;
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f; float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
for (int i = firstSmall; i < lastSmall; ++i) { for (int i = firstSmall; i < lastSmall; ++i) {

View File

@ -15,12 +15,13 @@ namespace ui {
enum class BottomMenuItem : int { enum class BottomMenuItem : int {
Play = 0, Play = 0,
Challenge = 1, Cooperate = 1,
Level = 2, Challenge = 2,
Options = 3, Level = 3,
Help = 4, Options = 4,
About = 5, Help = 5,
Exit = 6, About = 6,
Exit = 7,
}; };
struct Button { struct Button {
@ -34,11 +35,11 @@ struct BottomMenu {
std::array<Button, MENU_BTN_COUNT> buttons{}; std::array<Button, MENU_BTN_COUNT> buttons{};
}; };
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel); BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI);
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives. // Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
// hoveredIndex: -1..5 // hoveredIndex: -1..7
// selectedIndex: 0..5 (keyboard selection) // selectedIndex: 0..7 (keyboard selection)
// alphaMul: 0..1 (overall group alpha) // alphaMul: 0..1 (overall group alpha)
void renderBottomMenu(SDL_Renderer* renderer, void renderBottomMenu(SDL_Renderer* renderer,
FontAtlas* font, FontAtlas* font,

View File

@ -1,7 +1,8 @@
#include "ui/MenuLayout.h" #include "ui/MenuLayout.h"
#include "ui/UIConstants.h" #include "ui/UIConstants.h"
#include <cmath> #include <algorithm>
#include <array> #include <array>
#include <cmath>
namespace ui { 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; float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
// Cockpit HUD layout (matches main_screen art): // 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) // - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f); const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
const float marginBottom = std::max(26.0f, LOGICAL_H * 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; float smallSpacing = 26.0f;
// Scale down for narrow windows so nothing goes offscreen. // Scale down for narrow windows so nothing goes offscreen.
const int smallCount = MENU_BTN_COUNT - 2; const int bigCount = 3;
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1); const int smallCount = MENU_BTN_COUNT - bigCount;
float topRowTotal = playW * 2.0f + bigGap; 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) { if (smallTotal > availableW || topRowTotal > availableW) {
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f); float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
smallW *= s; 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; float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
std::array<SDL_FRect, MENU_BTN_COUNT> rects{}; std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
// Top row big buttons // Top row big buttons (PLAY / COOPERATE / CHALLENGE)
float playLeft = centerX - (playW + bigGap * 0.5f); float bigRowW = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
float challengeLeft = centerX + bigGap * 0.5f; float leftBig = centerX - bigRowW * 0.5f;
rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH }; for (int i = 0; i < bigCount; ++i) {
rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH }; 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 rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
float left = centerX - rowW * 0.5f; 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) { for (int i = 0; i < smallCount; ++i) {
float x = left + i * (smallW + smallSpacing); 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; return rects;
} }

View File

@ -17,7 +17,7 @@ struct MenuLayoutParams {
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p); std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
// Hit test a point given in logical content-local coordinates against menu buttons // 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); int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
// Return settings button rect (logical coords) // Return settings button rect (logical coords)

View File

@ -83,6 +83,6 @@ void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicE
bool sfxOn = true; 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 + 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 + 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}); font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, SDL_Color{200,200,220,255});
} }

View File

@ -1,6 +1,6 @@
#pragma once #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_SMALL_THRESHOLD = 700.0f;
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f; static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W

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

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

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

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

View File

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