Compare commits

..

5 Commits

Author SHA1 Message Date
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
29 changed files with 2756 additions and 108 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,6 +28,7 @@ find_package(SDL3_ttf CONFIG REQUIRED)
find_package(SDL3_image CONFIG REQUIRED) find_package(SDL3_image CONFIG REQUIRED)
find_package(cpr CONFIG REQUIRED) find_package(cpr CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED)
find_package(unofficial-enet CONFIG REQUIRED)
set(TETRIS_SOURCES set(TETRIS_SOURCES
src/main.cpp src/main.cpp
@ -46,6 +47,7 @@ set(TETRIS_SOURCES
src/graphics/renderers/RenderManager.cpp src/graphics/renderers/RenderManager.cpp
src/persistence/Scores.cpp src/persistence/Scores.cpp
src/network/supabase_client.cpp src/network/supabase_client.cpp
src/network/NetSession.cpp
src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield.cpp
src/graphics/effects/Starfield3D.cpp src/graphics/effects/Starfield3D.cpp
src/graphics/effects/SpaceWarp.cpp src/graphics/effects/SpaceWarp.cpp
@ -57,6 +59,7 @@ set(TETRIS_SOURCES
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
@ -66,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
@ -160,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
@ -194,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.

View File

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

0
scripts/check_braces.ps1 Normal file
View File

View File

View File

View File

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

View File

@ -49,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"
@ -57,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"
@ -259,6 +263,12 @@ struct TetrisApp::Impl {
double moveTimerMs = 0.0; double moveTimerMs = 0.0;
double p1MoveTimerMs = 0.0; double p1MoveTimerMs = 0.0;
double p2MoveTimerMs = 0.0; double p2MoveTimerMs = 0.0;
// Network coop fixed-tick state (used only when ctx.coopNetEnabled is true)
double coopNetAccMs = 0.0;
uint32_t coopNetCachedTick = 0xFFFFFFFFu;
uint8_t coopNetCachedButtons = 0;
uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu;
double DAS = 170.0; double DAS = 170.0;
double ARR = 40.0; double ARR = 40.0;
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
@ -301,11 +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();
@ -662,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);
@ -672,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(); });
@ -823,7 +861,7 @@ void TetrisApp::Impl::runLoop()
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
} }
const bool helpToggleKey = const bool helpToggleKey =
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu); (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu);
if (helpToggleKey) if (helpToggleKey)
{ {
showHelpOverlay = !showHelpOverlay; showHelpOverlay = !showHelpOverlay;
@ -1149,12 +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;
@ -1309,6 +1366,10 @@ void TetrisApp::Impl::runLoop()
if (game->isPaused()) { if (game->isPaused()) {
// While paused, suppress all continuous input changes so pieces don't drift. // While paused, suppress all continuous input changes so pieces don't drift.
if (ctx.coopNetEnabled && ctx.coopNetSession) {
ctx.coopNetSession->poll(0);
ctx.coopNetStalled = false;
}
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false); coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false); coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
p1MoveTimerMs = 0.0; p1MoveTimerMs = 0.0;
@ -1318,6 +1379,17 @@ void TetrisApp::Impl::runLoop()
p2LeftHeld = false; p2LeftHeld = false;
p2RightHeld = false; p2RightHeld = false;
} else { } else {
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
// If we just entered network co-op, reset per-session fixed-tick bookkeeping.
if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) {
coopNetAccMs = 0.0;
coopNetCachedTick = 0xFFFFFFFFu;
coopNetCachedButtons = 0;
coopNetLastHashSentTick = 0xFFFFFFFFu;
ctx.coopNetStalled = false;
}
// Define canonical key mappings for left and right players // Define canonical key mappings for left and right players
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A; const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
const SDL_Scancode leftRightKey = SDL_SCANCODE_D; const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
@ -1327,7 +1399,194 @@ void TetrisApp::Impl::runLoop()
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT; const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN; const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
if (!coopVsAI) { if (coopNetActive) {
// Network co-op: fixed tick lockstep.
// Use a fixed dt so both peers simulate identically.
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
ctx.coopNetSession->poll(0);
// If the connection drops during gameplay, abort back to menu.
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
: std::string("NET DISCONNECTED");
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
ctx.coopNetUiStatusText = reason;
ctx.coopNetUiStatusRemainingMs = 6000.0;
ctx.coopNetEnabled = false;
ctx.coopNetStalled = false;
ctx.coopNetDesyncDetected = false;
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
// Ensure we don't remain paused due to a previous net stall/desync.
if (game) {
game->setPaused(false);
}
state = AppState::Menu;
stateMgr->setState(state);
continue;
}
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
auto buildLocalButtons = [&]() -> uint8_t {
uint8_t b = 0;
if (ctx.coopNetLocalIsLeft) {
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
if (ks[leftRightKey]) b |= coopnet::MoveRight;
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
} else {
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
if (ks[rightRightKey]) b |= coopnet::MoveRight;
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
}
b |= ctx.coopNetPendingButtons;
ctx.coopNetPendingButtons = 0;
return b;
};
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
uint8_t buttons,
bool& leftHeldPrev,
bool& rightHeldPrev,
double& timer) {
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
coopGame->setSoftDropping(side, downHeldNow);
int moveDir = 0;
if (leftHeldNow && !rightHeldNow) moveDir = -1;
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
if (moveDir != 0) {
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
coopGame->move(side, moveDir);
timer = DAS;
} else {
timer -= FIXED_DT_MS;
if (timer <= 0.0) {
coopGame->move(side, moveDir);
timer += ARR;
}
}
} else {
timer = 0.0;
}
if (coopnet::has(buttons, coopnet::RotCW)) {
coopGame->rotate(side, +1);
}
if (coopnet::has(buttons, coopnet::RotCCW)) {
coopGame->rotate(side, -1);
}
if (coopnet::has(buttons, coopnet::HardDrop)) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
coopGame->hardDrop(side);
}
if (coopnet::has(buttons, coopnet::Hold)) {
coopGame->holdCurrent(side);
}
leftHeldPrev = leftHeldNow;
rightHeldPrev = rightHeldNow;
};
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
int safetySteps = 0;
bool advancedTick = false;
ctx.coopNetStalled = false;
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
const uint32_t tick = ctx.coopNetTick;
if (coopNetCachedTick != tick) {
coopNetCachedTick = tick;
coopNetCachedButtons = buildLocalButtons();
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
roleStr,
tick);
}
}
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
if (!remoteButtonsOpt.has_value()) {
if (!ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL begin waitingForTick=%u",
roleStr,
tick);
}
ctx.coopNetStalled = true;
break; // lockstep stall
}
const uint8_t remoteButtons = remoteButtonsOpt.value();
const bool localIsLeft = ctx.coopNetLocalIsLeft;
if (localIsLeft) {
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
} else {
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
}
coopGame->tickGravity(FIXED_DT_MS);
coopGame->updateVisualEffects(FIXED_DT_MS);
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
coopNetLastHashSentTick = tick;
const uint64_t hash = coopGame->computeStateHash();
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
roleStr,
tick,
(unsigned long long)hash);
}
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
if (rh.has_value() && rh.value() != hash) {
ctx.coopNetDesyncDetected = true;
ctx.coopNetUiStatusText = "NET DESYNC";
ctx.coopNetUiStatusRemainingMs = 8000.0;
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
roleStr,
tick,
(unsigned long long)hash,
(unsigned long long)rh.value());
game->setPaused(true);
}
}
ctx.coopNetTick++;
advancedTick = true;
coopNetAccMs -= FIXED_DT_MS;
}
if (advancedTick) {
if (ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL end atTick=%u",
roleStr,
ctx.coopNetTick);
}
ctx.coopNetStalled = false;
}
} else if (!coopVsAI) {
// Standard two-player: left uses WASD, right uses arrow keys // Standard two-player: left uses WASD, right uses arrow keys
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey); handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
@ -1357,8 +1616,10 @@ void TetrisApp::Impl::runLoop()
p2RightHeld = ks[rightRightKey]; p2RightHeld = ks[rightRightKey];
} }
coopGame->tickGravity(frameMs); if (!coopNetActive) {
coopGame->updateVisualEffects(frameMs); coopGame->tickGravity(frameMs);
coopGame->updateVisualEffects(frameMs);
}
} }
if (coopGame->isGameOver()) { if (coopGame->isGameOver()) {
@ -1387,6 +1648,12 @@ void TetrisApp::Impl::runLoop()
} }
state = AppState::GameOver; state = AppState::GameOver;
stateMgr->setState(state); stateMgr->setState(state);
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = false;
} }
} else { } else {
@ -1587,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 {
@ -1615,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);
} }
} }
@ -1682,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;
@ -1984,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);
@ -2377,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

@ -32,9 +32,19 @@
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include "../../utils/ImagePathResolver.h" #include "../../utils/ImagePathResolver.h"
#include <iostream> #include <iostream>
#include "../../video/VideoPlayer.h"
#include <cmath> #include <cmath>
#include <fstream> #include <fstream>
#include <algorithm> #include <algorithm>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <shellapi.h>
#endif
// (Intro video playback is now handled in-process via VideoPlayer)
ApplicationManager::ApplicationManager() = default; ApplicationManager::ApplicationManager() = default;
@ -55,7 +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) {
@ -780,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);
}
} }
}); });

View File

@ -153,6 +153,11 @@ private:
float m_logoAnimCounter = 0.0f; float m_logoAnimCounter = 0.0f;
bool m_helpOverlayPausedGame = false; bool m_helpOverlayPausedGame = false;
// Intro video playback (in-process via FFmpeg)
bool m_introStarted = false;
std::string m_introPath = "assets/videos/spacetris_intro.mp4";
std::unique_ptr<class VideoPlayer> m_videoPlayer;
// Gameplay background (per-level) with fade, mirroring main.cpp behavior // Gameplay background (per-level) with fade, mirroring main.cpp behavior
SDL_Texture* m_levelBackgroundTex = nullptr; SDL_Texture* m_levelBackgroundTex = nullptr;
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions

View File

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

@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstring>
namespace { namespace {
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell) // NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
@ -41,7 +42,23 @@ CoopGame::CoopGame(int startLevel_) {
reset(startLevel_); reset(startLevel_);
} }
void CoopGame::reset(int startLevel_) { namespace {
uint64_t fnv1a64(uint64_t h, const void* data, size_t size) {
const uint8_t* p = static_cast<const uint8_t*>(data);
for (size_t i = 0; i < size; ++i) {
h ^= static_cast<uint64_t>(p[i]);
h *= 1099511628211ull;
}
return h;
}
template <typename T>
uint64_t hashPod(uint64_t h, const T& v) {
return fnv1a64(h, &v, sizeof(T));
}
}
void CoopGame::resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt) {
std::fill(board.begin(), board.end(), Cell{}); std::fill(board.begin(), board.end(), Cell{});
rowStates.fill(RowHalfState{}); rowStates.fill(RowHalfState{});
completedLines.clear(); completedLines.clear();
@ -60,7 +77,7 @@ void CoopGame::reset(int startLevel_) {
left = PlayerState{}; left = PlayerState{};
right = PlayerState{ PlayerSide::Right }; right = PlayerState{ PlayerSide::Right };
auto initPlayer = [&](PlayerState& ps) { auto initPlayer = [&](PlayerState& ps, uint32_t seed) {
ps.canHold = true; ps.canHold = true;
ps.hold.type = PIECE_COUNT; ps.hold.type = PIECE_COUNT;
ps.softDropping = false; ps.softDropping = false;
@ -77,16 +94,34 @@ void CoopGame::reset(int startLevel_) {
ps.comboCount = 0; ps.comboCount = 0;
ps.bag.clear(); ps.bag.clear();
ps.next.type = PIECE_COUNT; ps.next.type = PIECE_COUNT;
ps.rng.seed(seed);
refillBag(ps); refillBag(ps);
}; };
initPlayer(left);
initPlayer(right); if (seedOpt.has_value()) {
const uint32_t seed = seedOpt.value();
initPlayer(left, seed);
initPlayer(right, seed ^ 0x9E3779B9u);
} else {
// Preserve existing behavior: random seed when not in deterministic mode.
std::random_device rd;
initPlayer(left, static_cast<uint32_t>(rd()));
initPlayer(right, static_cast<uint32_t>(rd()));
}
spawn(left); spawn(left);
spawn(right); spawn(right);
updateRowStates(); updateRowStates();
} }
void CoopGame::reset(int startLevel_) {
resetInternal(startLevel_, std::nullopt);
}
void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) {
resetInternal(startLevel_, seed);
}
void CoopGame::setSoftDropping(PlayerSide side, bool on) { void CoopGame::setSoftDropping(PlayerSide side, bool on) {
PlayerState& ps = player(side); PlayerState& ps = player(side);
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; }; auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
@ -103,6 +138,74 @@ void CoopGame::setSoftDropping(PlayerSide side, bool on) {
ps.softDropping = on; ps.softDropping = on;
} }
uint64_t CoopGame::computeStateHash() const {
uint64_t h = 1469598103934665603ull;
// Board
for (const auto& c : board) {
const uint8_t occ = c.occupied ? 1u : 0u;
const uint8_t owner = (c.owner == PlayerSide::Left) ? 0u : 1u;
const uint8_t val = static_cast<uint8_t>(std::clamp(c.value, 0, 255));
h = hashPod(h, occ);
h = hashPod(h, owner);
h = hashPod(h, val);
}
auto hashPiece = [&](const Piece& p) {
const uint8_t type = static_cast<uint8_t>(p.type);
const int32_t rot = p.rot;
const int32_t x = p.x;
const int32_t y = p.y;
h = hashPod(h, type);
h = hashPod(h, rot);
h = hashPod(h, x);
h = hashPod(h, y);
};
auto hashPlayer = [&](const PlayerState& ps) {
const uint8_t side = (ps.side == PlayerSide::Left) ? 0u : 1u;
h = hashPod(h, side);
hashPiece(ps.cur);
hashPiece(ps.next);
hashPiece(ps.hold);
const uint8_t canHoldB = ps.canHold ? 1u : 0u;
const uint8_t toppedOutB = ps.toppedOut ? 1u : 0u;
h = hashPod(h, canHoldB);
h = hashPod(h, toppedOutB);
h = hashPod(h, ps.score);
h = hashPod(h, ps.lines);
h = hashPod(h, ps.level);
h = hashPod(h, ps.tetrisesMade);
h = hashPod(h, ps.currentCombo);
h = hashPod(h, ps.maxCombo);
h = hashPod(h, ps.comboCount);
h = hashPod(h, ps.pieceSeq);
const uint32_t bagSize = static_cast<uint32_t>(ps.bag.size());
h = hashPod(h, bagSize);
for (auto t : ps.bag) {
const uint8_t tt = static_cast<uint8_t>(t);
h = hashPod(h, tt);
}
};
hashPlayer(left);
hashPlayer(right);
// Session-wide counters/stats
h = hashPod(h, _score);
h = hashPod(h, _lines);
h = hashPod(h, _level);
h = hashPod(h, _tetrisesMade);
h = hashPod(h, _currentCombo);
h = hashPod(h, _maxCombo);
h = hashPod(h, _comboCount);
h = hashPod(h, startLevel);
h = hashPod(h, pieceSequence);
return h;
}
void CoopGame::move(PlayerSide side, int dx) { void CoopGame::move(PlayerSide side, int dx) {
PlayerState& ps = player(side); PlayerState& ps = player(side);
if (gameOver || ps.toppedOut) return; if (gameOver || ps.toppedOut) return;

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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"
] ]
} }