diff --git a/.copilot-rules.md b/.copilot-rules.md new file mode 100644 index 0000000..cafc1cc --- /dev/null +++ b/.copilot-rules.md @@ -0,0 +1,168 @@ +# Copilot Rules — Spacetris (SDL3 / C++20) + +These rules define **non-negotiable constraints** for all AI-assisted changes. +They exist to preserve determinism, performance, and architecture. + +If these rules conflict with `.github/copilot-instructions.md`, +**follow `.github/copilot-instructions.md`.** + +--- + +## Project Constraints (Non-Negotiable) + +- Language: **C++20** +- Runtime: **SDL3** + **SDL3_ttf** +- Build system: **CMake** +- Dependencies via **vcpkg** +- Assets must use **relative paths only** +- Deterministic gameplay logic is mandatory + +Do not rewrite or refactor working systems unless explicitly requested. + +--- + +## Repo Layout & Responsibilities + +- Core gameplay loop/state: `src/Game.*` +- Entry point: `src/main.cpp` +- Text/TTF: `src/Font.*` +- Audio: `src/Audio.*`, `src/SoundEffect.*` +- Effects: `src/LineEffect.*`, `src/Starfield*.cpp` +- High scores: `src/Scores.*` +- Packaging: `build-production.ps1` + +When adding a module: +- Place it under `src/` (or an established subfolder) +- Register it in `CMakeLists.txt` +- Avoid circular includes +- Keep headers minimal + +--- + +## Build & Verification + +Prefer existing scripts: + +- Debug: `cmake --build build-msvc --config Debug` +- Release: + - Configure: `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release` + - Build: `cmake --build build-release --config Release` +- Packaging (Windows): `./build-production.ps1` + +Before finalizing changes: +- Debug build must succeed +- Packaging must succeed if assets or DLLs are touched + +Do not introduce new build steps unless required. + +--- + +## Coding & Architecture Rules + +- Match local file style (naming, braces, spacing) +- Avoid large refactors +- Prefer small, testable helpers +- Avoid floating-point math in core gameplay state +- Game logic must be deterministic +- Rendering code must not mutate game state + +--- + +## Rendering & Performance Rules + +- Do not allocate memory per frame +- Do not load assets during rendering +- No blocking calls in render loop +- Visual effects must be time-based (`deltaTime`) +- Rendering must not contain gameplay logic + +--- + +## Threading Rules + +- SDL main thread: + - Rendering + - Input + - Game simulation +- Networking must be **non-blocking** from the SDL main loop + - Either run networking on a separate thread, or poll ENet frequently with a 0 timeout + - Never wait/spin for remote inputs on the render thread +- Cross-thread communication via queues or buffers only + +--- + +## Assets, Fonts, and Paths + +- Runtime expects adjacent `assets/` directory +- `FreeSans.ttf` must remain at repo root +- New assets: + - Go under `assets/` + - Must be included in `build-production.ps1` + +Never hardcode machine-specific paths. + +--- + +## AI Partner (COOPERATE Mode) + +- AI is **supportive**, not competitive +- AI must respect sync timing and shared grid logic +- AI must not “cheat” or see hidden future pieces +- AI behavior must be deterministic per seed/difficulty + +--- + +## Networking (COOPERATE Network Mode) + +Follow `docs/ai/cooperate_network.md`. +If `network_cooperate_multiplayer.md` exists, keep it consistent with the canonical doc. + +Mandatory model: +- **Input lockstep** +- Transmit inputs only (no board state replication) + +Determinism requirements: +- Fixed tick (e.g. 60 Hz) +- Shared RNG seed +- Deterministic gravity, rotation, locking, scoring + +Technology: +- Use **ENet** +- Do NOT use SDL_net or TCP-only networking + +Architecture: +- Networking must be isolated (e.g. `src/network/NetSession.*`) +- Game logic must not care if partner is local, AI, or network + +Robustness: +- Input delay buffer (4–6 ticks) +- Periodic desync hashing +- Graceful disconnect handling + +Do NOT implement: +- Rollback +- Full state sync +- Server-authoritative sim +- Matchmaking SDKs +- Versus mechanics + +--- + +## Agent Behavior Rules (IMPORTANT) + +- Always read relevant markdown specs **before coding** +- Treat markdown specs as authoritative +- Do not invent APIs +- Do not assume external libraries exist +- Generate code **file by file**, not everything at once +- Ask before changing architecture or ownership boundaries + +--- + +## When to Ask Before Proceeding + +Ask the maintainer if unclear: +- UX or menu flow decisions +- Adding dependencies +- Refactors vs local patches +- Platform-specific behavior diff --git a/CMakeLists.txt b/CMakeLists.txt index 81ff6dd..e64436a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,7 @@ find_package(SDL3_ttf CONFIG REQUIRED) find_package(SDL3_image CONFIG REQUIRED) find_package(cpr CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) +find_package(unofficial-enet CONFIG REQUIRED) set(TETRIS_SOURCES src/main.cpp @@ -46,6 +47,7 @@ set(TETRIS_SOURCES src/graphics/renderers/RenderManager.cpp src/persistence/Scores.cpp src/network/supabase_client.cpp + src/network/NetSession.cpp src/graphics/effects/Starfield.cpp src/graphics/effects/Starfield3D.cpp src/graphics/effects/SpaceWarp.cpp @@ -57,6 +59,7 @@ set(TETRIS_SOURCES src/audio/Audio.cpp src/gameplay/effects/LineEffect.cpp src/audio/SoundEffect.cpp + src/video/VideoPlayer.cpp src/ui/MenuLayout.cpp src/ui/BottomMenu.cpp src/app/BackgroundManager.cpp @@ -66,6 +69,7 @@ set(TETRIS_SOURCES src/states/LoadingManager.cpp # State implementations (new) src/states/LoadingState.cpp + src/states/VideoState.cpp src/states/MenuState.cpp src/states/OptionsState.cpp src/states/LevelSelectorState.cpp @@ -160,10 +164,17 @@ if(APPLE) endif() endif() -target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json) +target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet) + +find_package(FFMPEG REQUIRED) +if(FFMPEG_FOUND) + target_include_directories(spacetris PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_directories(spacetris PRIVATE ${FFMPEG_LIBRARY_DIRS}) + target_link_libraries(spacetris PRIVATE ${FFMPEG_LIBRARIES}) +endif() if (WIN32) - target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid) + target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm) endif() if(APPLE) # Needed for MP3 decoding via AudioToolbox on macOS @@ -194,6 +205,7 @@ endif() target_include_directories(spacetris PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src/audio + ${CMAKE_SOURCE_DIR}/src/video ${CMAKE_SOURCE_DIR}/src/gameplay ${CMAKE_SOURCE_DIR}/src/graphics ${CMAKE_SOURCE_DIR}/src/persistence diff --git a/assets/videos/spacetris_intro.mp4 b/assets/videos/spacetris_intro.mp4 new file mode 100644 index 0000000..f53718f Binary files /dev/null and b/assets/videos/spacetris_intro.mp4 differ diff --git a/docs/ai/cooperate_network.md b/docs/ai/cooperate_network.md new file mode 100644 index 0000000..36f9882 --- /dev/null +++ b/docs/ai/cooperate_network.md @@ -0,0 +1,271 @@ +# Spacetris — COOPERATE Mode +## Network Multiplayer (2 PLAYER – NETWORK) +### VS Code Copilot AI Agent Prompt + +You are integrating **online cooperative multiplayer** into an existing **C++ / SDL3 game** called **Spacetris**. + +This feature extends the existing **COOPERATE mode** to support: +- Local 2 players +- Human + AI +- **Human + Human over network (NEW)** + +The networking solution must be **deterministic, lightweight, and stable**. + +--- + +## 1. High-Level Goal + +Add **COOPERATE 2 PLAYER (NETWORK)** mode where: +- Two players play together over the internet +- Each player controls one half of the shared grid +- A line clears only when both halves are filled +- Gameplay remains identical to local COOPERATE mode + +--- + +## 2. Technology Constraints + +- Language: **C++** +- Engine: **SDL3** +- Networking: **ENet (UDP with reliability)** +- No engine rewrite +- No authoritative server logic required (co-op only) + +SDL3 is used ONLY for: +- Rendering +- Input +- Timing + +Networking is a **separate layer**. + +--- + +## 3. Network Model (MANDATORY) + +### Use **Input Lockstep Networking** + +#### Core idea: +- Both clients run the same deterministic simulation +- Only **player inputs** are sent over the network +- No board state is transmitted +- Both simulations must remain identical + +This model is ideal for Tetris-like games. + +--- + +## 4. Determinism Requirements (CRITICAL) + +To ensure lockstep works: + +- Fixed simulation tick (e.g. 60 Hz) +- Identical RNG seed for both clients +- Deterministic piece generation (bag system) +- No floating-point math in core gameplay +- Same gravity, rotation, lock-delay logic +- Identical line clear and scoring rules + +Before networking: +- Input recording + replay must produce identical results + +--- + +## 5. Network Topology + +### Host / Client Model (Initial Implementation) + +- One player hosts the game +- One player joins +- Host is authoritative for: + - RNG seed + - start tick + - game settings + +This is sufficient and fair for cooperative gameplay. + +--- + +## 6. Network Library + +Use **ENet** for: +- Reliable, ordered UDP packets +- Low latency +- Simple integration with C++ + +Do NOT use: +- SDL_net +- TCP-only networking +- High-level matchmaking SDKs + +--- + +## 7. Network Packet Design + +### Input Packet (Minimal) + +```cpp +struct InputPacket { + uint32_t tick; + uint8_t buttons; // bitmask +}; +```` + +Button bitmask example: + +* bit 0 → move left +* bit 1 → move right +* bit 2 → rotate +* bit 3 → soft drop +* bit 4 → hard drop +* bit 5 → hold + +Packets must be: + +* Reliable +* Ordered +* Small + +--- + +## 8. Tick & Latency Handling + +### Input Delay Buffer (RECOMMENDED) + +* Add fixed delay: **4–6 ticks** +* Simulate tick `T` using inputs for `T + delay` +* Prevents stalls due to latency spikes + +Strict lockstep without buffering is NOT recommended. + +--- + +## 9. Desync Detection (IMPORTANT) + +Every N ticks (e.g. once per second): + +* Compute a hash of: + + * Both grid halves + * Active pieces + * RNG index + * Score / lines / level +* Exchange hashes +* If mismatch: + + * Log desync + * Stop game or mark session invalid + +This is required for debugging and stability. + +--- + +## 10. Network Session Architecture + +Create a dedicated networking module: + +``` +/network + NetSession.h + NetSession.cpp +``` + +Responsibilities: + +* ENet host/client setup +* Input packet send/receive +* Tick synchronization +* Latency buffering +* Disconnect handling + +SDL main loop must NOT block on networking. + +--- + +## 11. Integration with Existing COOPERATE Logic + +* COOPERATE grid logic stays unchanged +* SyncLineRenderer remains unchanged +* Scoring logic remains unchanged +* Network layer only injects **remote inputs** + +Game logic should not know whether partner is: + +* Local human +* AI +* Network player + +--- + +## 12. UI Integration (Menu Changes) + +In COOPERATE selection screen, add a new button: + +``` +[ LOCAL CO-OP ] [ AI PARTNER ] [ 2 PLAYER (NETWORK) ] +``` + +### On selecting 2 PLAYER (NETWORK): + +* Show: + + * Host Game + * Join Game +* Display join code or IP +* Confirm connection before starting + +--- + +## 13. Start Game Flow (Network) + +1. Host creates session +2. Client connects +3. Host sends: + + * RNG seed + * start tick + * game settings +4. Both wait until agreed start tick +5. Simulation begins simultaneously + +--- + +## 14. Disconnect & Error Handling + +* If connection drops: + + * Pause game + * Show “Reconnecting…” + * After timeout: + + * End match or switch to AI (optional) +* Never crash +* Never corrupt game state + +--- + +## 15. What NOT to Implement + +* ❌ Full state synchronization +* ❌ Prediction / rollback +* ❌ Server-authoritative gameplay +* ❌ Complex matchmaking +* ❌ Versus mechanics + +This is cooperative, not competitive. + +--- + +## 16. Acceptance Criteria + +* Two players can complete COOPERATE mode over network +* Gameplay matches local COOPERATE exactly +* No noticeable input lag under normal latency +* Desync detection works +* Offline / disconnect handled gracefully +* SDL3 render loop remains smooth + +--- + +## 17. Summary for Copilot + +Integrate networked cooperative multiplayer into Spacetris using SDL3 + C++ with ENet. Implement input lockstep networking with deterministic simulation, fixed tick rate, input buffering, and desync detection. Add a new COOPERATE menu option “2 PLAYER (NETWORK)” that allows host/join flow. Networking must be modular, non-blocking, and transparent to existing gameplay logic. diff --git a/scripts/check_braces.ps1 b/scripts/check_braces.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_comments.ps1 b/scripts/check_comments.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/find_unmatched.ps1 b/scripts/find_unmatched.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/settings.ini b/settings.ini index 7e2630a..99029f7 100644 --- a/settings.ini +++ b/settings.ini @@ -14,7 +14,7 @@ SmoothScroll=1 UpRotateClockwise=0 [Player] -Name=P2 +Name=GREGOR [Debug] Enabled=1 diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 0d9d0fc..0bd4d0b 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -49,6 +49,9 @@ #include "graphics/ui/Font.h" #include "graphics/ui/HelpOverlay.h" +#include "network/CoopNetButtons.h" +#include "network/NetSession.h" + #include "persistence/Scores.h" #include "states/LevelSelectorState.h" @@ -57,6 +60,7 @@ #include "states/MenuState.h" #include "states/OptionsState.h" #include "states/PlayingState.h" +#include "states/VideoState.h" #include "states/State.h" #include "ui/BottomMenu.h" @@ -259,6 +263,12 @@ struct TetrisApp::Impl { double moveTimerMs = 0.0; double p1MoveTimerMs = 0.0; double p2MoveTimerMs = 0.0; + + // Network coop fixed-tick state (used only when ctx.coopNetEnabled is true) + double coopNetAccMs = 0.0; + uint32_t coopNetCachedTick = 0xFFFFFFFFu; + uint8_t coopNetCachedButtons = 0; + uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu; double DAS = 170.0; double ARR = 40.0; SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H}; @@ -301,11 +311,21 @@ struct TetrisApp::Impl { std::unique_ptr stateMgr; StateContext ctx{}; std::unique_ptr loadingState; + std::unique_ptr videoState; std::unique_ptr menuState; std::unique_ptr optionsState; std::unique_ptr levelSelectorState; std::unique_ptr 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(); void runLoop(); void shutdown(); @@ -662,7 +682,11 @@ int TetrisApp::Impl::init() }; ctx.requestFadeTransition = requestStateFade; + ctx.startupFadeActive = &startupFadeActive; + ctx.startupFadeAlpha = &startupFadeAlpha; + loadingState = std::make_unique(ctx); + videoState = std::make_unique(ctx); menuState = std::make_unique(ctx); optionsState = std::make_unique(ctx); levelSelectorState = std::make_unique(ctx); @@ -672,6 +696,20 @@ int TetrisApp::Impl::init() stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); 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->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); @@ -823,7 +861,7 @@ void TetrisApp::Impl::runLoop() Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); } 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) { 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(); double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency()); lastMs = now; if (frameMs > 100.0) frameMs = 100.0; 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]() { challengeStoryText.clear(); challengeStoryLevel = 0; @@ -1309,6 +1366,10 @@ void TetrisApp::Impl::runLoop() if (game->isPaused()) { // While paused, suppress all continuous input changes so pieces don't drift. + if (ctx.coopNetEnabled && ctx.coopNetSession) { + ctx.coopNetSession->poll(0); + ctx.coopNetStalled = false; + } coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false); coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false); p1MoveTimerMs = 0.0; @@ -1318,6 +1379,17 @@ void TetrisApp::Impl::runLoop() p2LeftHeld = false; p2RightHeld = false; } else { + const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession; + + // If we just entered network co-op, reset per-session fixed-tick bookkeeping. + if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) { + coopNetAccMs = 0.0; + coopNetCachedTick = 0xFFFFFFFFu; + coopNetCachedButtons = 0; + coopNetLastHashSentTick = 0xFFFFFFFFu; + ctx.coopNetStalled = false; + } + // Define canonical key mappings for left and right players const SDL_Scancode leftLeftKey = SDL_SCANCODE_A; const SDL_Scancode leftRightKey = SDL_SCANCODE_D; @@ -1327,7 +1399,194 @@ void TetrisApp::Impl::runLoop() const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT; 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 handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey); handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey); @@ -1357,8 +1616,10 @@ void TetrisApp::Impl::runLoop() p2RightHeld = ks[rightRightKey]; } - coopGame->tickGravity(frameMs); - coopGame->updateVisualEffects(frameMs); + if (!coopNetActive) { + coopGame->tickGravity(frameMs); + coopGame->updateVisualEffects(frameMs); + } } if (coopGame->isGameOver()) { @@ -1387,6 +1648,12 @@ void TetrisApp::Impl::runLoop() } state = AppState::GameOver; stateMgr->setState(state); + + if (ctx.coopNetSession) { + ctx.coopNetSession->shutdown(); + ctx.coopNetSession.reset(); + } + ctx.coopNetEnabled = false; } } else { @@ -1587,7 +1854,15 @@ void TetrisApp::Impl::runLoop() if (totalTasks > 0) { loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks)); 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); } } else { @@ -1615,7 +1890,15 @@ void TetrisApp::Impl::runLoop() if (loadingProgress > 0.99) loadingProgress = 1.0; if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0; 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); } } @@ -1682,6 +1965,9 @@ void TetrisApp::Impl::runLoop() case AppState::Loading: loadingState->update(frameMs); break; + case AppState::Video: + if (videoState) videoState->update(frameMs); + break; case AppState::Menu: menuState->update(frameMs); break; @@ -1984,6 +2270,11 @@ void TetrisApp::Impl::runLoop() } } break; + case AppState::Video: + if (videoState) { + videoState->render(renderer, logicalScale, logicalVP); + } + break; case AppState::Menu: if (!mainScreenTex) { mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); @@ -2377,6 +2668,17 @@ void TetrisApp::Impl::runLoop() 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_SetRenderScale(renderer, 1.f, 1.f); } diff --git a/src/core/Settings.cpp b/src/core/Settings.cpp index 2f5e200..3982a4f 100644 --- a/src/core/Settings.cpp +++ b/src/core/Settings.cpp @@ -21,7 +21,11 @@ std::string Settings::getSettingsPath() { bool Settings::load() { std::ifstream file(getSettingsPath()); if (!file.is_open()) { - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults"); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults. Creating settings file with defaults."); + // Persist defaults so next run has an explicit settings.ini + try { + save(); + } catch (...) {} return false; } diff --git a/src/core/Settings.h b/src/core/Settings.h index f29d75b..6e158a4 100644 --- a/src/core/Settings.h +++ b/src/core/Settings.h @@ -48,7 +48,8 @@ private: Settings& operator=(const Settings&) = delete; // Settings values - bool m_fullscreen = false; + // Default to fullscreen on first run when no settings.ini exists + bool m_fullscreen = true; bool m_musicEnabled = true; bool m_soundEnabled = true; bool m_debugEnabled = false; diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index f919208..9e4da60 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -32,9 +32,19 @@ #include #include "../../utils/ImagePathResolver.h" #include +#include "../../video/VideoPlayer.h" #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif +// (Intro video playback is now handled in-process via VideoPlayer) 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); app->m_starfield3D->draw(renderer.getSDLRenderer()); } - + // If intro video is playing, render it instead of the loading UI + if (app->m_introStarted && app->m_videoPlayer) { + SDL_Renderer* sdlR = renderer.getSDLRenderer(); + int winW=0, winH=0; renderer.getWindowSize(winW, winH); + app->m_videoPlayer->render(sdlR, winW, winH); + SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr); + SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f); + return; + } SDL_Rect logicalVP = {0,0,0,0}; float logicalScale = 1.0f; if (app->m_renderManager) { @@ -780,17 +798,44 @@ void ApplicationManager::setupStateHandlers() { m_starfield3D->update(deltaTime / 1000.0f); } - // Check if loading is complete and transition to menu + // Check if loading is complete and transition to next stage if (m_assetManager->isLoadingComplete()) { - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu"); - + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow"); + // Update texture pointers now that assets are loaded m_stateContext.backgroundTex = m_assetManager->getTexture("background"); m_stateContext.blocksTex = m_assetManager->getTexture("blocks"); - - bool ok = m_stateManager->setState(AppState::Menu); - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0); - traceFile("- to Menu returned"); + + // If an intro video exists and hasn't been started, attempt to play it in-process + std::filesystem::path introPath = m_introPath; + if (!m_introStarted && std::filesystem::exists(introPath)) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str()); + try { + if (!m_videoPlayer) m_videoPlayer = std::make_unique(); + 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); + } } }); diff --git a/src/core/application/ApplicationManager.h b/src/core/application/ApplicationManager.h index ea6e43c..ee6002a 100644 --- a/src/core/application/ApplicationManager.h +++ b/src/core/application/ApplicationManager.h @@ -153,6 +153,11 @@ private: float m_logoAnimCounter = 0.0f; bool m_helpOverlayPausedGame = false; + // Intro video playback (in-process via FFmpeg) + bool m_introStarted = false; + std::string m_introPath = "assets/videos/spacetris_intro.mp4"; + std::unique_ptr m_videoPlayer; + // Gameplay background (per-level) with fade, mirroring main.cpp behavior SDL_Texture* m_levelBackgroundTex = nullptr; SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions diff --git a/src/core/state/StateManager.cpp b/src/core/state/StateManager.cpp index 598e016..7d526a2 100644 --- a/src/core/state/StateManager.cpp +++ b/src/core/state/StateManager.cpp @@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) { } bool StateManager::isValidState(AppState state) const { - // All enum values are currently valid - return static_cast(state) >= static_cast(AppState::Loading) && - static_cast(state) <= static_cast(AppState::GameOver); + switch (state) { + case AppState::Loading: + case AppState::Video: + case AppState::Menu: + case AppState::Options: + case AppState::LevelSelector: + case AppState::Playing: + case AppState::LevelSelect: + case AppState::GameOver: + return true; + default: + return false; + } } bool StateManager::canTransitionTo(AppState newState) const { @@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const { const char* StateManager::getStateName(AppState state) const { switch (state) { case AppState::Loading: return "Loading"; + case AppState::Video: return "Video"; case AppState::Menu: return "Menu"; case AppState::Options: return "Options"; case AppState::LevelSelector: return "LevelSelector"; diff --git a/src/core/state/StateManager.h b/src/core/state/StateManager.h index 7805475..20379da 100644 --- a/src/core/state/StateManager.h +++ b/src/core/state/StateManager.h @@ -12,6 +12,7 @@ class RenderManager; // Application states used across the app enum class AppState { Loading, + Video, Menu, Options, LevelSelector, diff --git a/src/gameplay/coop/CoopGame.cpp b/src/gameplay/coop/CoopGame.cpp index e6676bc..010978f 100644 --- a/src/gameplay/coop/CoopGame.cpp +++ b/src/gameplay/coop/CoopGame.cpp @@ -2,6 +2,7 @@ #include #include +#include namespace { // NES (NTSC) gravity table reused from single-player for level progression (ms per cell) @@ -41,7 +42,23 @@ CoopGame::CoopGame(int 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(data); + for (size_t i = 0; i < size; ++i) { + h ^= static_cast(p[i]); + h *= 1099511628211ull; + } + return h; +} + +template +uint64_t hashPod(uint64_t h, const T& v) { + return fnv1a64(h, &v, sizeof(T)); +} +} + +void CoopGame::resetInternal(int startLevel_, const std::optional& seedOpt) { std::fill(board.begin(), board.end(), Cell{}); rowStates.fill(RowHalfState{}); completedLines.clear(); @@ -60,7 +77,7 @@ void CoopGame::reset(int startLevel_) { left = PlayerState{}; right = PlayerState{ PlayerSide::Right }; - auto initPlayer = [&](PlayerState& ps) { + auto initPlayer = [&](PlayerState& ps, uint32_t seed) { ps.canHold = true; ps.hold.type = PIECE_COUNT; ps.softDropping = false; @@ -77,16 +94,34 @@ void CoopGame::reset(int startLevel_) { ps.comboCount = 0; ps.bag.clear(); ps.next.type = PIECE_COUNT; + ps.rng.seed(seed); 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(rd())); + initPlayer(right, static_cast(rd())); + } spawn(left); spawn(right); updateRowStates(); } +void CoopGame::reset(int startLevel_) { + resetInternal(startLevel_, std::nullopt); +} + +void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) { + resetInternal(startLevel_, seed); +} + void CoopGame::setSoftDropping(PlayerSide side, bool on) { PlayerState& ps = player(side); auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; }; @@ -103,6 +138,74 @@ void CoopGame::setSoftDropping(PlayerSide side, bool 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(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(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(ps.bag.size()); + h = hashPod(h, bagSize); + for (auto t : ps.bag) { + const uint8_t tt = static_cast(t); + h = hashPod(h, tt); + } + }; + + hashPlayer(left); + hashPlayer(right); + + // Session-wide counters/stats + h = hashPod(h, _score); + h = hashPod(h, _lines); + h = hashPod(h, _level); + h = hashPod(h, _tetrisesMade); + h = hashPod(h, _currentCombo); + h = hashPod(h, _maxCombo); + h = hashPod(h, _comboCount); + h = hashPod(h, startLevel); + h = hashPod(h, pieceSequence); + + return h; +} + void CoopGame::move(PlayerSide side, int dx) { PlayerState& ps = player(side); if (gameOver || ps.toppedOut) return; diff --git a/src/gameplay/coop/CoopGame.h b/src/gameplay/coop/CoopGame.h index 3377a50..996b775 100644 --- a/src/gameplay/coop/CoopGame.h +++ b/src/gameplay/coop/CoopGame.h @@ -62,9 +62,13 @@ public: void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; } void reset(int startLevel = 0); + void resetDeterministic(int startLevel, uint32_t seed); void tickGravity(double frameMs); void updateVisualEffects(double frameMs); + // Determinism / desync detection + uint64_t computeStateHash() const; + // Per-player inputs ----------------------------------------------------- void setSoftDropping(PlayerSide side, bool on); void move(PlayerSide side, int dx); @@ -111,6 +115,8 @@ public: private: static constexpr double LOCK_DELAY_MS = 500.0; + void resetInternal(int startLevel_, const std::optional& seedOpt); + std::array board{}; std::array rowStates{}; PlayerState left{}; diff --git a/src/network/CoopNetButtons.h b/src/network/CoopNetButtons.h new file mode 100644 index 0000000..68d6ec5 --- /dev/null +++ b/src/network/CoopNetButtons.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +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(b)) != 0; +} +} diff --git a/src/network/NetSession.cpp b/src/network/NetSession.cpp new file mode 100644 index 0000000..026fc67 --- /dev/null +++ b/src/network/NetSession.cpp @@ -0,0 +1,324 @@ +#include "NetSession.h" + +#include + +#include + +#include + +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 +static void append(std::vector& out, const T& value) { + const uint8_t* p = reinterpret_cast(&value); + out.insert(out.end(), p, p + sizeof(T)); +} + +template +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(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 buf; + buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t)); + buf.push_back(static_cast(MsgType::Handshake)); + append(buf, hs.rngSeed); + append(buf, hs.startTick); + append(buf, hs.startLevel); + return sendBytesReliable(buf.data(), buf.size()); +} + +std::optional 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 buf; + buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t)); + buf.push_back(static_cast(MsgType::Input)); + append(buf, tick); + append(buf, buttons); + return sendBytesReliable(buf.data(), buf.size()); +} + +std::optional 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 buf; + buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t)); + buf.push_back(static_cast(MsgType::Hash)); + append(buf, tick); + append(buf, hash); + return sendBytesReliable(buf.data(), buf.size()); +} + +std::optional 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(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; + } +} diff --git a/src/network/NetSession.h b/src/network/NetSession.h new file mode 100644 index 0000000..d02da9f --- /dev/null +++ b/src/network/NetSession.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 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 getRemoteButtons(uint32_t tick) const; + + // Hash exchange (for desync detection) --------------------------------- + bool sendStateHash(uint32_t tick, uint64_t hash); + std::optional 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 m_receivedHandshake; + + std::unordered_map m_remoteInputs; + std::unordered_map 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; +}; diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index bdcd0fa..3b9bcb1 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -1,6 +1,7 @@ #include "MenuState.h" #include "persistence/Scores.h" #include "../network/supabase_client.h" +#include "../network/NetSession.h" #include "graphics/Font.h" #include "../graphics/ui/HelpOverlay.h" #include "../core/GlobalState.h" @@ -16,6 +17,7 @@ #include #include #include +#include // Use dynamic logical dimensions from GlobalState instead of hardcoded values // 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; coopSetupDirection = 1; coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0; + coopSetupStep = CoopSetupStep::ChoosePartner; + coopNetworkRoleSelected = 0; + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) { + SDL_StopTextInput(focusWin); + } + if (coopNetworkSession) { + coopNetworkSession->shutdown(); + coopNetworkSession.reset(); + } coopSetupRectsValid = false; selectedButton = static_cast(ui::BottomMenuItem::Cooperate); // 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; coopSetupDirection = -1; coopSetupRectsValid = false; + + if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) { + SDL_StopTextInput(focusWin); + } + + // Cancel any pending network session if the coop setup is being closed. + if (coopNetworkSession) { + coopNetworkSession->shutdown(); + coopNetworkSession.reset(); + } + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + coopSetupStep = CoopSetupStep::ChoosePartner; // Resume menu music only when requested (ESC should pass resumeMusic=false) if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) { Audio::instance().playMenuMusic(); @@ -280,58 +306,196 @@ void MenuState::onExit() { } void MenuState::handleEvent(const SDL_Event& e) { + // Text input for network IP entry (only while coop setup panel is active). + if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_TEXT_INPUT) { + if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) { + std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress; + if (target.size() < 64) { + target += e.text.text; + } + return; + } + } + // Coop setup panel navigation (modal within the menu) // Handle this FIRST and consume key events so the main menu navigation doesn't interfere. // Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat. if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) { + // Coop setup panel navigation (modal within the menu) switch (e.key.scancode) { + case SDL_SCANCODE_UP: + case SDL_SCANCODE_DOWN: + // Do NOT allow up/down to change anything while this panel is active + return; + case SDL_SCANCODE_ESCAPE: + // When in a nested network step, go back one step; otherwise close the panel. + if (coopSetupStep == CoopSetupStep::NetworkChooseRole) { + coopSetupStep = CoopSetupStep::ChoosePartner; + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + return; + } + if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) { + if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) { + SDL_StopTextInput(focusWin); + } + coopSetupStep = CoopSetupStep::NetworkChooseRole; + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + return; + } + if (coopSetupStep == CoopSetupStep::NetworkWaiting) { + if (coopNetworkSession) { + coopNetworkSession->shutdown(); + coopNetworkSession.reset(); + } + if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) { + SDL_StopTextInput(focusWin); + } + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + coopSetupStep = CoopSetupStep::NetworkChooseRole; + return; + } + showCoopSetupPanel(false, false); + return; case SDL_SCANCODE_LEFT: case SDL_SCANCODE_A: - coopSetupSelected = 0; - buttonFlash = 1.0; + if (coopSetupStep == CoopSetupStep::ChoosePartner) { + // 3-way selection: LOCAL / AI / NETWORK + coopSetupSelected = (coopSetupSelected + 3 - 1) % 3; + buttonFlash = 1.0; + return; + } + if (coopSetupStep == CoopSetupStep::NetworkChooseRole) { + coopNetworkRoleSelected = (coopNetworkRoleSelected + 2 - 1) % 2; + buttonFlash = 1.0; + return; + } + if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) { + return; + } return; case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_D: - coopSetupSelected = 1; - buttonFlash = 1.0; - return; - // Do NOT allow up/down to change anything - case SDL_SCANCODE_UP: - case SDL_SCANCODE_DOWN: - return; - case SDL_SCANCODE_ESCAPE: - showCoopSetupPanel(false, false); + if (coopSetupStep == CoopSetupStep::ChoosePartner) { + coopSetupSelected = (coopSetupSelected + 1) % 3; + buttonFlash = 1.0; + return; + } + if (coopSetupStep == CoopSetupStep::NetworkChooseRole) { + coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2; + buttonFlash = 1.0; + return; + } + if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) { + return; + } return; + case SDL_SCANCODE_BACKSPACE: + if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) { + std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress; + if (!target.empty()) target.pop_back(); + return; + } + break; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_SPACE: { - const bool useAI = (coopSetupSelected == 1); - if (ctx.coopVsAI) { - *ctx.coopVsAI = useAI; - } - if (ctx.game) { - ctx.game->setMode(GameMode::Cooperate); - ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); - } - if (ctx.coopGame) { - ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); + // Existing flows (Local 2P / AI) are preserved exactly. + if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) { + const bool useAI = (coopSetupSelected == 1); + if (ctx.coopVsAI) { + *ctx.coopVsAI = useAI; + } + if (ctx.game) { + ctx.game->setMode(GameMode::Cooperate); + ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); + } + if (ctx.coopGame) { + ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0); + } + + // Close the panel without restarting menu music; gameplay will take over. + showCoopSetupPanel(false, false); + + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p", + coopSetupSelected, + ctx.startPlayTransition ? 1 : 0, + (void*)ctx.stateManager); + + if (ctx.startPlayTransition) { + ctx.startPlayTransition(); + } else if (ctx.stateManager) { + ctx.stateManager->setState(AppState::Playing); + } + return; } - // 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); + // Network flow (new): choose host/join, confirm connection before starting. + if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) { + coopSetupStep = CoopSetupStep::NetworkChooseRole; + coopNetworkRoleSelected = 0; + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + if (coopNetworkSession) { + coopNetworkSession->shutdown(); + coopNetworkSession.reset(); + } + buttonFlash = 1.0; + return; } + + if (coopSetupStep == CoopSetupStep::NetworkChooseRole) { + // First, let the user enter the address (bind for host, remote for join). + coopSetupStep = CoopSetupStep::NetworkEnterAddress; + coopNetworkStatusText.clear(); + if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) { + SDL_StartTextInput(focusWin); + } + return; + } + + if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) { + coopNetworkHandshakeSent = false; + coopNetworkStatusText.clear(); + coopNetworkSession = std::make_unique(); + + const uint16_t port = coopNetworkPort; + bool ok = false; + if (coopNetworkRoleSelected == 0) { + const std::string bindIp = coopNetworkBindAddress; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST start bind=%s port=%u", bindIp.c_str(), (unsigned)port); + ok = coopNetworkSession->host(bindIp, port); + coopNetworkStatusText = ok ? "WAITING FOR PLAYER..." : ("HOST FAILED: " + coopNetworkSession->lastError()); + } else { + const std::string joinIp = coopNetworkJoinAddress.empty() ? std::string("127.0.0.1") : coopNetworkJoinAddress; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] JOIN start remote=%s port=%u", joinIp.c_str(), (unsigned)port); + ok = coopNetworkSession->join(joinIp, port); + coopNetworkStatusText = ok ? "CONNECTING..." : ("JOIN FAILED: " + coopNetworkSession->lastError()); + } + + if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) { + SDL_StopTextInput(focusWin); + } + + if (ok) { + coopSetupStep = CoopSetupStep::NetworkWaiting; + } else { + // Stay on role choice screen so user can back out. + coopNetworkSession.reset(); + coopSetupStep = CoopSetupStep::NetworkChooseRole; + } + return; + } + + // While waiting for connection, Enter does nothing. return; } default: - // Allow 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; } } @@ -796,6 +960,15 @@ void MenuState::handleEvent(const SDL_Event& e) { } 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 GlobalState::instance().logoAnimCounter += frameMs; // 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; useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255}); 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 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"; - if (selectedButton == 0) wantedType = "classic"; // Play / Endless - else if (selectedButton == 1) wantedType = "cooperate"; // Coop - else if (selectedButton == 2) wantedType = "challenge"; // Challenge + if (activeBtn == 0) wantedType = "classic"; // Play / Endless + else if (activeBtn == 1) wantedType = "cooperate"; // Coop + else if (activeBtn == 2) wantedType = "challenge"; // Challenge // Filter highscores to the desired game type std::vector filtered; 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). const float panelBaseY = scoresStartY - 20.0f; - // Make the choice buttons smaller, add more spacing, and raise them higher - const float btnW2 = std::min(300.0f, panelW * 0.30f); + // Choice buttons (partner selection) and nested network host/join UI const float btnH2 = 60.0f; - const float gap = 96.0f; - // Shift the image and buttons to the right for layout balance (reduced) - const float shiftX = 20.0f; // move right by 30px (moved 20px left from previous) - const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f + shiftX; + const float gap = 34.0f; + const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f); + const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f; + // Shift the image and buttons slightly for layout balance + const float shiftX = 20.0f; + const float bx = panelBaseX + (panelW - totalChoiceW) * 0.5f + shiftX; // Move the buttons up by ~80px to sit closer under the logo const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f; coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 }; - coopSetupBtnRects[1] = SDL_FRect{ bx + btnW2 + gap, by, btnW2, btnH2 }; + coopSetupBtnRects[1] = SDL_FRect{ bx + (btnW2 + gap), by, btnW2, btnH2 }; + coopSetupBtnRects[2] = SDL_FRect{ bx + (btnW2 + gap) * 2.0f, by, btnW2, btnH2 }; coopSetupRectsValid = true; SDL_Color bg{ 24, 36, 52, 220 }; @@ -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 float alphaFactor = static_cast(coopSetupTransition); if (alphaFactor < 0.0f) alphaFactor = 0.0f; if (alphaFactor > 1.0f) alphaFactor = 1.0f; + // Compute coop info image placement (draw as background for both ChoosePartner and Network steps) + float imgX = 0.0f, imgY = 0.0f, targetW = 0.0f, targetH = 0.0f; + bool hasCoopImg = false; if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) { - float totalW = btnW2 * 2.0f + gap; - // Increase allowed image width by ~15% (was 0.75 of totalW) - const float scaleFactor = 0.75f * 1.25f; // ~0.8625 - float maxImgW = totalW * scaleFactor; - float targetW = std::min(maxImgW, static_cast(coopInfoTexW)); + float totalW = totalChoiceW; + // Keep coop info image slightly smaller than the button row. + // Use a modest scale so it doesn't dominate the UI. + float maxImgW = totalW * 0.65f; + targetW = std::min(maxImgW, static_cast(coopInfoTexW)); float scale = targetW / static_cast(coopInfoTexW); - float targetH = static_cast(coopInfoTexH) * scale; - float imgX = bx + (totalW - targetW) * 0.5f; - float imgY = by - targetH - 8.0f; // keep the small gap above buttons + targetH = static_cast(coopInfoTexH) * scale; + imgX = bx + (totalW - targetW) * 0.5f; + imgY = by - targetH - 8.0f; // keep the small gap above buttons float minY = panelBaseY + 6.0f; if (imgY < minY) imgY = minY; SDL_FRect dst{ imgX, imgY, targetW, targetH }; @@ -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 SDL_SetTextureAlphaMod(coopInfoTexture, static_cast(std::round(200.0f * alphaFactor))); SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst); - - // Draw cooperative instructions inside the panel area (overlayed on the panel background) - FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; - if (f) { - const float pad = 38.0f; - float textX = panelBaseX + pad; - // Position the text over the lower portion of the image (overlay) - // Move the block upward by ~150px to match UI request - float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f; + hasCoopImg = true; - // Bulleted list (measure sample line height first) - const std::vector bullets = { - "The playfield is shared between two players", - "Each player controls one half of the grid", - "A line clears only when both halves are filled", - "Timing and coordination are essential" - }; - float bulletScale = 0.78f; - SDL_Color bulletCol{200,220,230,220}; - bulletCol.a = static_cast(std::round(bulletCol.a * alphaFactor)); - int sampleLW = 0, sampleLH = 0; - f->measure(bullets[0], bulletScale, sampleLW, sampleLH); + // Only draw the instructional overlay text when choosing partner. + if (coopSetupStep == CoopSetupStep::ChoosePartner) { + FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; + if (f) { + const float pad = 38.0f; + float textX = panelBaseX + pad; + // Position the text over the lower portion of the image (overlay) + // Move the block upward by ~150px to match UI request + float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f; + + // Bulleted list (measure sample line height first) + const std::vector bullets = { + "The playfield is shared between two players", + "Each player controls one half of the grid", + "A line clears only when both halves are filled", + "Timing and coordination are essential" + }; + float bulletScale = 0.78f; + SDL_Color bulletCol{200,220,230,220}; + bulletCol.a = static_cast(std::round(bulletCol.a * alphaFactor)); + int sampleLW = 0, sampleLH = 0; + f->measure(bullets[0], bulletScale, sampleLW, sampleLH); // Header: move it up by one sample row so it sits higher const std::string header = "* HOW TO PLAY – COOPERATE MODE *"; @@ -1470,6 +1663,7 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi f->draw(renderer, goalX, textY, goalText, 0.86f, goalTextCol); } } + } // Delay + eased fade specifically for the two coop buttons so they appear after the image/text. const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading @@ -1479,10 +1673,134 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi float buttonFade = rawBtn * rawBtn; SDL_Color bgA = bg; bgA.a = static_cast(std::round(bgA.a * buttonFade)); SDL_Color borderA = border; borderA.a = static_cast(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); - UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[1].x + btnW2 * 0.5f, coopSetupBtnRects[1].y + btnH2 * 0.5f, - btnW2, btnH2, "COMPUTER (AI)", false, coopSetupSelected == 1, bgA, borderA, false, nullptr); + + // Step 1: choose partner mode + if (coopSetupStep == CoopSetupStep::ChoosePartner) { + UIRenderer::drawButton(renderer, ctx.pixelFont, + coopSetupBtnRects[0].x + btnW2 * 0.5f, + coopSetupBtnRects[0].y + btnH2 * 0.5f, + btnW2, btnH2, + "LOCAL CO-OP", + false, + coopSetupSelected == 0, + bgA, + borderA, + false, + nullptr); + + UIRenderer::drawButton(renderer, ctx.pixelFont, + coopSetupBtnRects[1].x + btnW2 * 0.5f, + coopSetupBtnRects[1].y + btnH2 * 0.5f, + btnW2, btnH2, + "AI PARTNER", + false, + coopSetupSelected == 1, + bgA, + borderA, + false, + nullptr); + + UIRenderer::drawButton(renderer, ctx.pixelFont, + coopSetupBtnRects[2].x + btnW2 * 0.5f, + coopSetupBtnRects[2].y + btnH2 * 0.5f, + btnW2, btnH2, + "2 PLAYER (NET)", + false, + coopSetupSelected == 2, + bgA, + borderA, + false, + nullptr); + } + + // Step 2: network host/join selection and address entry + if (coopSetupStep == CoopSetupStep::NetworkChooseRole || coopSetupStep == CoopSetupStep::NetworkEnterAddress || coopSetupStep == CoopSetupStep::NetworkWaiting) { + // Draw two buttons centered under the main row. + const float roleBtnW = std::min(280.0f, panelW * 0.30f); + const float roleGap = 48.0f; + const float roleTotalW = roleBtnW * 2.0f + roleGap; + const float roleX = panelBaseX + (panelW - roleTotalW) * 0.5f + shiftX; + // Move the host/join buttons down from the previous higher position. + // Shift down by one button height plus half a button (effectively lower them): + const float roleY = by + (btnH2 * 0.5f) - 18.0f; + + SDL_FRect hostRect{ roleX, roleY, roleBtnW, btnH2 }; + SDL_FRect joinRect{ roleX + roleBtnW + roleGap, roleY, roleBtnW, btnH2 }; + + UIRenderer::drawButton(renderer, ctx.pixelFont, + hostRect.x + roleBtnW * 0.5f, + hostRect.y + btnH2 * 0.5f, + roleBtnW, + btnH2, + "HOST GAME", + false, + coopNetworkRoleSelected == 0, + bgA, + borderA, + false, + nullptr); + + UIRenderer::drawButton(renderer, ctx.pixelFont, + joinRect.x + roleBtnW * 0.5f, + joinRect.y + btnH2 * 0.5f, + roleBtnW, + btnH2, + "JOIN GAME", + false, + coopNetworkRoleSelected == 1, + bgA, + borderA, + false, + nullptr); + + FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont; + if (f) { + SDL_Color infoCol{200, 220, 230, static_cast(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(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(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(std::round(200.0f * buttonFade))}; + f->draw(renderer, textX, hintY, "PRESS ENTER TO EDIT/CONFIRM ESC TO GO BACK", 0.82f, hintCol); + } + } + } } // NOTE: slide-up COOP panel intentionally removed. Only the inline // highscores-area choice buttons are shown when coop setup is active. @@ -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); } } + + // 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(rd()); + if (seed == 0u) seed = 1u; + const uint8_t startLevel = static_cast(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(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(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(); + } + } + } + } + } } diff --git a/src/states/MenuState.h b/src/states/MenuState.h index 20d41ae..0da134d 100644 --- a/src/states/MenuState.h +++ b/src/states/MenuState.h @@ -2,6 +2,12 @@ #pragma once #include "State.h" +#include +#include +#include + +class NetSession; + class MenuState : public State { public: MenuState(StateContext& ctx); @@ -105,8 +111,27 @@ private: double coopSetupTransition = 0.0; // 0..1 double coopSetupTransitionDurationMs = 320.0; int coopSetupDirection = 1; // 1 show, -1 hide - int coopSetupSelected = 0; // 0 = 2 players, 1 = AI - SDL_FRect coopSetupBtnRects[2]{}; + // 0 = Local co-op (2 players), 1 = AI partner, 2 = 2 player (network) + int coopSetupSelected = 0; + + enum class CoopSetupStep { + ChoosePartner, + NetworkChooseRole, + NetworkEnterAddress, + NetworkWaiting, + }; + CoopSetupStep coopSetupStep = CoopSetupStep::ChoosePartner; + + // Network sub-flow state (only used when coopSetupSelected == 2) + int coopNetworkRoleSelected = 0; // 0 = host, 1 = join + std::string coopNetworkBindAddress = "0.0.0.0"; + std::string coopNetworkJoinAddress = "127.0.0.1"; + uint16_t coopNetworkPort = 7777; + bool coopNetworkHandshakeSent = false; + std::string coopNetworkStatusText; + std::unique_ptr coopNetworkSession; + + SDL_FRect coopSetupBtnRects[3]{}; bool coopSetupRectsValid = false; // Optional cooperative info image shown when coop setup panel is active SDL_Texture* coopInfoTexture = nullptr; diff --git a/src/states/PlayingState.cpp b/src/states/PlayingState.cpp index e6f0138..bd2daeb 100644 --- a/src/states/PlayingState.cpp +++ b/src/states/PlayingState.cpp @@ -6,9 +6,11 @@ #include "../persistence/Scores.h" #include "../audio/Audio.h" #include "../audio/SoundEffect.h" +#include "../graphics/Font.h" #include "../graphics/renderers/GameRenderer.h" #include "../core/Settings.h" #include "../core/Config.h" +#include "../network/CoopNetButtons.h" #include // 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.startLevelSelection) { SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection); - ctx.game->reset(*ctx.startLevelSelection); - if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) { - ctx.coopGame->reset(*ctx.startLevelSelection); + const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession; + + // For network co-op, MenuState already performed a deterministic reset using the negotiated seed. + // Re-resetting here would overwrite it (and will desync). + if (!coopNetActive) { + ctx.game->reset(*ctx.startLevelSelection); + if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) { + ctx.coopGame->reset(*ctx.startLevelSelection); + } + } else { + ctx.game->setPaused(false); } } } else { @@ -46,6 +56,18 @@ void PlayingState::onExit() { SDL_DestroyTexture(m_renderTarget); m_renderTarget = nullptr; } + + // If we are leaving gameplay during network co-op, tear down the session so + // hosting/joining again works without restarting the app. + if (ctx.coopNetSession) { + ctx.coopNetSession->shutdown(); + ctx.coopNetSession.reset(); + } + ctx.coopNetEnabled = false; + ctx.coopNetStalled = false; + ctx.coopNetDesyncDetected = false; + ctx.coopNetTick = 0; + ctx.coopNetPendingButtons = 0; } void PlayingState::handleEvent(const SDL_Event& e) { @@ -135,6 +157,10 @@ void PlayingState::handleEvent(const SDL_Event& e) { // Pause toggle (P) - matches classic behavior; disabled during countdown if (e.key.scancode == SDL_SCANCODE_P) { + // Network co-op uses lockstep; local pause would desync/stall the peer. + if (ctx.coopNetEnabled && ctx.coopNetSession) { + return; + } const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) || (ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed); if (!countdown) { @@ -149,6 +175,49 @@ void PlayingState::handleEvent(const SDL_Event& e) { } if (coopActive && ctx.coopGame) { + // Network co-op: route one-shot actions into a pending bitmask for lockstep. + if (ctx.coopNetEnabled && ctx.coopNetSession) { + const bool localIsLeft = ctx.coopNetLocalIsLeft; + const SDL_Scancode sc = e.key.scancode; + if (localIsLeft) { + if (sc == SDL_SCANCODE_W) { + ctx.coopNetPendingButtons |= coopnet::RotCW; + return; + } + if (sc == SDL_SCANCODE_Q) { + ctx.coopNetPendingButtons |= coopnet::RotCCW; + return; + } + if (sc == SDL_SCANCODE_LSHIFT || sc == SDL_SCANCODE_E) { + ctx.coopNetPendingButtons |= coopnet::HardDrop; + return; + } + if (sc == SDL_SCANCODE_LCTRL) { + ctx.coopNetPendingButtons |= coopnet::Hold; + return; + } + } else { + if (sc == SDL_SCANCODE_UP) { + const bool upIsCW = Settings::instance().isUpRotateClockwise(); + ctx.coopNetPendingButtons |= upIsCW ? coopnet::RotCW : coopnet::RotCCW; + return; + } + if (sc == SDL_SCANCODE_RALT) { + ctx.coopNetPendingButtons |= coopnet::RotCCW; + return; + } + if (sc == SDL_SCANCODE_SPACE || sc == SDL_SCANCODE_RSHIFT) { + ctx.coopNetPendingButtons |= coopnet::HardDrop; + return; + } + if (sc == SDL_SCANCODE_RCTRL) { + ctx.coopNetPendingButtons |= coopnet::Hold; + return; + } + } + // If coopNet is active, suppress local co-op direct action keys. + } + const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI); // Player 1 (left): when AI is enabled it controls the left side so @@ -313,6 +382,31 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l // But countdown should definitely NOT show the "PAUSED" overlay. bool shouldBlur = paused && !countdown && !challengeClearFx; + auto renderNetOverlay = [&]() { + if (!coopActive || !ctx.coopNetEnabled || !ctx.pixelFont) return; + if (!ctx.coopNetDesyncDetected && !ctx.coopNetStalled) return; + + const char* text = ctx.coopNetDesyncDetected ? "NET: DESYNC" : "NET: STALLED"; + SDL_Color textColor = ctx.coopNetDesyncDetected ? SDL_Color{255, 230, 180, 255} : SDL_Color{255, 224, 130, 255}; + float scale = 0.75f; + int tw = 0, th = 0; + ctx.pixelFont->measure(text, scale, tw, th); + + SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE; + SDL_GetRenderDrawBlendMode(renderer, &prevBlend); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + + const float pad = 8.0f; + const float x = 18.0f; + const float y = 14.0f; + SDL_FRect bg{ x - pad, y - pad, (float)tw + pad * 2.0f, (float)th + pad * 2.0f }; + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160); + SDL_RenderFillRect(renderer, &bg); + ctx.pixelFont->draw(renderer, x, y, text, scale, textColor); + + SDL_SetRenderDrawBlendMode(renderer, prevBlend); + }; + if (shouldBlur && m_renderTarget) { // Render game to texture SDL_SetRenderTarget(renderer, m_renderTarget); @@ -421,6 +515,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l SDL_SetRenderViewport(renderer, &oldVP); SDL_SetRenderScale(renderer, oldSX, oldSY); + // Net overlay (on top of blurred game, under pause/exit overlays) + renderNetOverlay(); + // Draw overlays if (exitPopup) { GameRenderer::renderExitPopup( @@ -466,6 +563,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l (float)winW, (float)winH ); + + // Net overlay (on top of coop HUD) + renderNetOverlay(); } else { GameRenderer::renderPlayingState( renderer, diff --git a/src/states/State.h b/src/states/State.h index 032c14f..f8fc226 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -6,6 +6,9 @@ #include #include #include +#include + +#include "../network/NetSession.h" // Forward declarations for frequently used types class Game; @@ -81,12 +84,31 @@ struct StateContext { std::string* playerName = nullptr; // Shared player name buffer for highscores/options // Coop setting: when true, COOPERATE runs with a computer-controlled right player. bool* coopVsAI = nullptr; + + // COOPERATE (network) -------------------------------------------------- + // These fields are only meaningful when `coopNetEnabled` is true. + bool coopNetEnabled = false; + bool coopNetIsHost = false; + bool coopNetLocalIsLeft = true; // host = left (WASD), client = right (arrows) + uint32_t coopNetRngSeed = 0; + uint32_t coopNetTick = 0; + uint8_t coopNetPendingButtons = 0; // one-shot actions captured from keydown (rotate/hold/harddrop) + bool coopNetStalled = false; // true when waiting for remote input for current tick + bool coopNetDesyncDetected = false; + std::string coopNetUiStatusText; // transient status shown in menu after net abort + double coopNetUiStatusRemainingMs = 0.0; + std::unique_ptr coopNetSession; bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available std::function applyFullscreen; // Allows states to request fullscreen changes std::function queryFullscreen; // Optional callback if fullscreenFlag is not reliable std::function requestQuit; // Allows menu/option states to close the app gracefully std::function startPlayTransition; // Optional fade hook when transitioning from menu to gameplay std::function requestFadeTransition; // Generic state fade requests (menu/options/level) + + // Startup transition fade (used for intro video -> main). + // When active, the app should render a black overlay with alpha = startupFadeAlpha*255. + bool* startupFadeActive = nullptr; + float* startupFadeAlpha = nullptr; // Pointer to the application's StateManager so states can request transitions StateManager* stateManager = nullptr; // Optional explicit per-button coordinates (logical coordinates). When diff --git a/src/states/VideoState.cpp b/src/states/VideoState.cpp new file mode 100644 index 0000000..10360dc --- /dev/null +++ b/src/states/VideoState.cpp @@ -0,0 +1,389 @@ +// VideoState.cpp +#include "VideoState.h" + +#include "../video/VideoPlayer.h" +#include "../audio/Audio.h" +#include "../core/state/StateManager.h" + +#include + +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +VideoState::VideoState(StateContext& ctx) + : State(ctx) + , m_player(std::make_unique()) +{ +} + +VideoState::~VideoState() { + onExit(); +} + +bool VideoState::begin(SDL_Renderer* renderer, const std::string& path) { + m_path = path; + + if (!m_player) { + m_player = std::make_unique(); + } + + 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([this](std::stop_token st) { + (void)st; + std::vector 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& 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 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 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(); +} diff --git a/src/states/VideoState.h b/src/states/VideoState.h new file mode 100644 index 0000000..f6a4642 --- /dev/null +++ b/src/states/VideoState.h @@ -0,0 +1,67 @@ +// VideoState.h +#pragma once + +#include "State.h" + +#include +#include +#include +#include +#include + +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& outPcm, + int& outRate, + int& outChannels + ); + + std::unique_ptr 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 m_audioDecoded{false}; + std::atomic m_audioDecodeFailed{false}; + std::vector m_audioPcm; + int m_audioRate = 44100; + int m_audioChannels = 2; + bool m_audioStarted = false; + + std::unique_ptr m_audioThread; + + // Render-time overlay alpha (0..1) for fade stages. + float m_blackOverlayAlpha = 1.0f; +}; diff --git a/src/video/VideoPlayer.cpp b/src/video/VideoPlayer.cpp new file mode 100644 index 0000000..d97d587 --- /dev/null +++ b/src/video/VideoPlayer.cpp @@ -0,0 +1,172 @@ +#include "VideoPlayer.h" + +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +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); +} diff --git a/src/video/VideoPlayer.h b/src/video/VideoPlayer.h new file mode 100644 index 0000000..1be8e27 --- /dev/null +++ b/src/video/VideoPlayer.h @@ -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 +#include + +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; +}; diff --git a/vcpkg.json b/vcpkg.json index 7116b91..f93b924 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -6,8 +6,10 @@ "name": "sdl3-image", "features": ["jpeg", "png", "webp"] }, + "enet", "catch2", "cpr", - "nlohmann-json" + "nlohmann-json", + "ffmpeg" ] } \ No newline at end of file