Compare commits

...

11 Commits

Author SHA1 Message Date
60d6a9e740 Merge branch 'release/v0.1.0'
Some checks failed
Build and Package Spacetris / build-windows (push) Has been cancelled
Build and Package Spacetris / build-linux (push) Has been cancelled
2025-12-25 10:03:42 +01:00
e1921858ed Merge branch 'feature/NetworkMultiplayerCooperate' into develop 2025-12-25 09:38:19 +01:00
14cb96345c Added intro video 2025-12-25 09:38:06 +01:00
d28feb3276 minor fixes 2025-12-23 20:24:50 +01:00
a7a3ae9055 added basic network play 2025-12-23 19:03:33 +01:00
5ec4bf926b added rules 2025-12-23 17:16:12 +01:00
0e04617968 Merge branch 'feature/CooperateAiPlayer' into develop 2025-12-23 16:50:51 +01:00
b450e2af21 fixed menu 2025-12-23 14:49:55 +01:00
a65756f298 fixed menu 2025-12-23 14:12:37 +01:00
dac312ef2b updated main menu for cooperate mode 2025-12-23 12:21:33 +01:00
953d6af701 fixed cooperate play 2025-12-22 21:26:56 +01:00
34 changed files with 3577 additions and 99 deletions

168
.copilot-rules.md Normal file
View File

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

View File

@ -28,12 +28,14 @@ find_package(SDL3_ttf CONFIG REQUIRED)
find_package(SDL3_image CONFIG REQUIRED)
find_package(cpr CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(unofficial-enet CONFIG REQUIRED)
set(TETRIS_SOURCES
src/main.cpp
src/app/TetrisApp.cpp
src/gameplay/core/Game.cpp
src/gameplay/coop/CoopGame.cpp
src/gameplay/coop/CoopAIController.cpp
src/core/GravityManager.cpp
src/core/state/StateManager.cpp
# New core architecture classes
@ -45,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
@ -56,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
@ -65,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
@ -159,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
@ -193,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

View File

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

0
scripts/check_braces.ps1 Normal file
View File

View File

View File

View File

@ -5,7 +5,7 @@
Fullscreen=1
[Audio]
Music=0
Music=1
Sound=1
[Gameplay]

View File

@ -38,6 +38,7 @@
#include "gameplay/core/Game.h"
#include "gameplay/coop/CoopGame.h"
#include "gameplay/coop/CoopAIController.h"
#include "gameplay/effects/LineEffect.h"
#include "graphics/effects/SpaceWarp.h"
@ -48,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"
@ -56,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"
@ -239,6 +244,11 @@ struct TetrisApp::Impl {
bool suppressLineVoiceForLevelUp = false;
bool skipNextLevelUpJingle = false;
// COOPERATE option: when true, right player is AI-controlled.
bool coopVsAI = false;
CoopAIController coopAI;
AppState state = AppState::Loading;
double loadingProgress = 0.0;
Uint64 loadStart = 0;
@ -253,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};
@ -295,11 +311,21 @@ struct TetrisApp::Impl {
std::unique_ptr<StateManager> stateMgr;
StateContext ctx{};
std::unique_ptr<LoadingState> loadingState;
std::unique_ptr<VideoState> videoState;
std::unique_ptr<MenuState> menuState;
std::unique_ptr<OptionsState> optionsState;
std::unique_ptr<LevelSelectorState> levelSelectorState;
std::unique_ptr<PlayingState> playingState;
// Startup fade-in overlay (used after intro video).
bool startupFadeActive = false;
float startupFadeAlpha = 0.0f; // 0..1 black overlay strength
double startupFadeClockMs = 0.0;
static constexpr double STARTUP_FADE_IN_MS = 650.0;
// Intro video path.
std::string introVideoPath = "assets/videos/spacetris_intro.mp4";
int init();
void runLoop();
void shutdown();
@ -567,6 +593,7 @@ int TetrisApp::Impl::init()
ctx.mainScreenW = mainScreenW;
ctx.mainScreenH = mainScreenH;
ctx.musicEnabled = &musicEnabled;
ctx.coopVsAI = &coopVsAI;
ctx.startLevelSelection = &startLevelSelection;
ctx.hoveredButton = &hoveredButton;
ctx.showSettingsPopup = &showSettingsPopup;
@ -628,10 +655,17 @@ int TetrisApp::Impl::init()
return;
}
if (state != AppState::Menu) {
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
coopAI.reset();
}
state = AppState::Playing;
ctx.stateManager->setState(state);
return;
}
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
coopAI.reset();
}
beginStateFade(AppState::Playing, true);
};
ctx.startPlayTransition = startMenuPlayTransition;
@ -648,7 +682,11 @@ int TetrisApp::Impl::init()
};
ctx.requestFadeTransition = requestStateFade;
ctx.startupFadeActive = &startupFadeActive;
ctx.startupFadeAlpha = &startupFadeAlpha;
loadingState = std::make_unique<LoadingState>(ctx);
videoState = std::make_unique<VideoState>(ctx);
menuState = std::make_unique<MenuState>(ctx);
optionsState = std::make_unique<OptionsState>(ctx);
levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
@ -658,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(); });
@ -809,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;
@ -894,27 +946,45 @@ void TetrisApp::Impl::runLoop()
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
if (isNewHighScore) {
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
// Two-name entry flow
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (highScoreEntryIndex == 0) {
if (playerName.empty()) playerName = "P1";
highScoreEntryIndex = 1; // move to second name
} else {
if (coopVsAI) {
// One-name entry flow (CPU is LEFT, human enters RIGHT name)
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
if (!player2Name.empty()) player2Name.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (player2Name.empty()) player2Name = "P2";
// Submit combined name
std::string combined = playerName + " & " + player2Name;
std::string combined = std::string("CPU") + " & " + player2Name;
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
int combinedScore = leftScore + rightScore;
ensureScoresLoaded();
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
Settings::instance().setPlayerName(playerName);
Settings::instance().setPlayerName(player2Name);
isNewHighScore = false;
SDL_StopTextInput(window);
}
} else {
// Two-name entry flow
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back();
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
if (highScoreEntryIndex == 0) {
if (playerName.empty()) playerName = "P1";
highScoreEntryIndex = 1; // move to second name
} else {
if (player2Name.empty()) player2Name = "P2";
// Submit combined name
std::string combined = playerName + " & " + player2Name;
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
int combinedScore = leftScore + rightScore;
ensureScoresLoaded();
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
Settings::instance().setPlayerName(playerName);
isNewHighScore = false;
SDL_StopTextInput(window);
}
}
}
} else {
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
@ -972,11 +1042,9 @@ void TetrisApp::Impl::runLoop()
startMenuPlayTransition();
break;
case ui::BottomMenuItem::Cooperate:
if (game) {
game->setMode(GameMode::Cooperate);
game->reset(startLevelSelection);
if (menuState) {
menuState->showCoopSetupPanel(true);
}
startMenuPlayTransition();
break;
case ui::BottomMenuItem::Challenge:
if (game) {
@ -1119,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;
@ -1279,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;
@ -1288,16 +1379,247 @@ void TetrisApp::Impl::runLoop()
p2LeftHeld = false;
p2RightHeld = false;
} else {
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
p1LeftHeld = ks[SDL_SCANCODE_A];
p1RightHeld = ks[SDL_SCANCODE_D];
p2LeftHeld = ks[SDL_SCANCODE_LEFT];
p2RightHeld = ks[SDL_SCANCODE_RIGHT];
// 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;
}
coopGame->tickGravity(frameMs);
coopGame->updateVisualEffects(frameMs);
// Define canonical key mappings for left and right players
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
const SDL_Scancode leftDownKey = SDL_SCANCODE_S;
const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT;
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
if (coopNetActive) {
// Network co-op: fixed tick lockstep.
// Use a fixed dt so both peers simulate identically.
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
ctx.coopNetSession->poll(0);
// If the connection drops during gameplay, abort back to menu.
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
: std::string("NET DISCONNECTED");
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
ctx.coopNetUiStatusText = reason;
ctx.coopNetUiStatusRemainingMs = 6000.0;
ctx.coopNetEnabled = false;
ctx.coopNetStalled = false;
ctx.coopNetDesyncDetected = false;
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
// Ensure we don't remain paused due to a previous net stall/desync.
if (game) {
game->setPaused(false);
}
state = AppState::Menu;
stateMgr->setState(state);
continue;
}
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
auto buildLocalButtons = [&]() -> uint8_t {
uint8_t b = 0;
if (ctx.coopNetLocalIsLeft) {
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
if (ks[leftRightKey]) b |= coopnet::MoveRight;
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
} else {
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
if (ks[rightRightKey]) b |= coopnet::MoveRight;
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
}
b |= ctx.coopNetPendingButtons;
ctx.coopNetPendingButtons = 0;
return b;
};
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
uint8_t buttons,
bool& leftHeldPrev,
bool& rightHeldPrev,
double& timer) {
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
coopGame->setSoftDropping(side, downHeldNow);
int moveDir = 0;
if (leftHeldNow && !rightHeldNow) moveDir = -1;
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
if (moveDir != 0) {
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
coopGame->move(side, moveDir);
timer = DAS;
} else {
timer -= FIXED_DT_MS;
if (timer <= 0.0) {
coopGame->move(side, moveDir);
timer += ARR;
}
}
} else {
timer = 0.0;
}
if (coopnet::has(buttons, coopnet::RotCW)) {
coopGame->rotate(side, +1);
}
if (coopnet::has(buttons, coopnet::RotCCW)) {
coopGame->rotate(side, -1);
}
if (coopnet::has(buttons, coopnet::HardDrop)) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
coopGame->hardDrop(side);
}
if (coopnet::has(buttons, coopnet::Hold)) {
coopGame->holdCurrent(side);
}
leftHeldPrev = leftHeldNow;
rightHeldPrev = rightHeldNow;
};
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
int safetySteps = 0;
bool advancedTick = false;
ctx.coopNetStalled = false;
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
const uint32_t tick = ctx.coopNetTick;
if (coopNetCachedTick != tick) {
coopNetCachedTick = tick;
coopNetCachedButtons = buildLocalButtons();
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
roleStr,
tick);
}
}
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
if (!remoteButtonsOpt.has_value()) {
if (!ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL begin waitingForTick=%u",
roleStr,
tick);
}
ctx.coopNetStalled = true;
break; // lockstep stall
}
const uint8_t remoteButtons = remoteButtonsOpt.value();
const bool localIsLeft = ctx.coopNetLocalIsLeft;
if (localIsLeft) {
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
} else {
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
}
coopGame->tickGravity(FIXED_DT_MS);
coopGame->updateVisualEffects(FIXED_DT_MS);
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
coopNetLastHashSentTick = tick;
const uint64_t hash = coopGame->computeStateHash();
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
roleStr,
tick,
(unsigned long long)hash);
}
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
if (rh.has_value() && rh.value() != hash) {
ctx.coopNetDesyncDetected = true;
ctx.coopNetUiStatusText = "NET DESYNC";
ctx.coopNetUiStatusRemainingMs = 8000.0;
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
roleStr,
tick,
(unsigned long long)hash,
(unsigned long long)rh.value());
game->setPaused(true);
}
}
ctx.coopNetTick++;
advancedTick = true;
coopNetAccMs -= FIXED_DT_MS;
}
if (advancedTick) {
if (ctx.coopNetStalled) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP][%s] STALL end atTick=%u",
roleStr,
ctx.coopNetTick);
}
ctx.coopNetStalled = false;
}
} else if (!coopVsAI) {
// Standard two-player: left uses WASD, right uses arrow keys
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
p1LeftHeld = ks[leftLeftKey];
p1RightHeld = ks[leftRightKey];
p2LeftHeld = ks[rightLeftKey];
p2RightHeld = ks[rightRightKey];
} else {
// Coop vs CPU: AI controls LEFT, human controls RIGHT (arrow keys).
// Handle continuous input for the human on the right side.
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
// Mirror the human soft-drop to the AI-controlled left board so both fall together.
const bool pRightSoftDrop = ks[rightDownKey];
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, pRightSoftDrop);
// Reset left continuous timers/held flags (AI handles movement)
p1MoveTimerMs = 0.0;
p1LeftHeld = false;
p1RightHeld = false;
// Update AI for the left side
coopAI.update(*coopGame, CoopGame::PlayerSide::Left, frameMs);
// Update human-held flags for right-side controls so DAS/ARR state is tracked
p2LeftHeld = ks[rightLeftKey];
p2RightHeld = ks[rightRightKey];
}
if (!coopNetActive) {
coopGame->tickGravity(frameMs);
coopGame->updateVisualEffects(frameMs);
}
}
if (coopGame->isGameOver()) {
@ -1307,17 +1629,31 @@ void TetrisApp::Impl::runLoop()
int combinedScore = leftScore + rightScore;
if (combinedScore > 0) {
isNewHighScore = true;
playerName.clear();
player2Name.clear();
highScoreEntryIndex = 0;
if (coopVsAI) {
// AI is left, prompt human (right) for name
playerName = "CPU";
player2Name.clear();
highScoreEntryIndex = 1; // enter P2 (human)
} else {
playerName.clear();
player2Name.clear();
highScoreEntryIndex = 0;
}
SDL_StartTextInput(window);
} else {
isNewHighScore = false;
ensureScoresLoaded();
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate");
// When AI is present, label should indicate CPU left and human right
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), coopVsAI ? "CPU & P2" : "P1 & P2", "cooperate");
}
state = AppState::GameOver;
stateMgr->setState(state);
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = false;
}
} else {
@ -1518,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 {
@ -1546,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);
}
}
@ -1613,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;
@ -1915,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);
@ -2308,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);
}

View File

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

View File

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

View File

@ -32,9 +32,19 @@
#include <SDL3_ttf/SDL_ttf.h>
#include "../../utils/ImagePathResolver.h"
#include <iostream>
#include "../../video/VideoPlayer.h"
#include <cmath>
#include <fstream>
#include <algorithm>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <shellapi.h>
#endif
// (Intro video playback is now handled in-process via VideoPlayer)
ApplicationManager::ApplicationManager() = default;
@ -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<VideoPlayer>();
SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr;
if (m_videoPlayer->open(introPath.string(), sdlRend)) {
m_introStarted = true;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process");
} else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping");
m_stateManager->setState(AppState::Playing);
}
} catch (const std::exception& ex) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what());
m_stateManager->setState(AppState::Playing);
}
} else if (m_introStarted) {
// Let VideoPlayer decode frames; once finished, transition to playing
if (m_videoPlayer) m_videoPlayer->update();
if (!m_videoPlayer || m_videoPlayer->isFinished()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing");
m_stateManager->setState(AppState::Playing);
}
} else {
// No intro to play; transition directly to Playing
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing");
m_stateManager->setState(AppState::Playing);
}
}
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,317 @@
#include "CoopAIController.h"
#include "CoopGame.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
namespace {
static bool canPlacePieceForSide(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
const CoopGame::Piece& p,
CoopGame::PlayerSide side) {
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(p, cx, cy)) {
continue;
}
const int bx = p.x + cx;
const int by = p.y + cy;
// Keep the AI strictly in the correct half.
if (side == CoopGame::PlayerSide::Right) {
if (bx < 10 || bx >= CoopGame::COLS) {
return false;
}
} else {
if (bx < 0 || bx >= 10) {
return false;
}
}
// Above the visible board is allowed.
if (by < 0) {
continue;
}
if (by >= CoopGame::ROWS) {
return false;
}
if (board[by * CoopGame::COLS + bx].occupied) {
return false;
}
}
}
return true;
}
static int dropYFor(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
CoopGame::Piece p,
CoopGame::PlayerSide side) {
// Assumes p is currently placeable.
while (true) {
CoopGame::Piece next = p;
next.y += 1;
if (!canPlacePieceForSide(board, next, side)) {
return p.y;
}
p = next;
if (p.y > CoopGame::ROWS) {
return p.y;
}
}
}
static void applyPiece(std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS>& occ,
const CoopGame::Piece& p) {
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(p, cx, cy)) {
continue;
}
const int bx = p.x + cx;
const int by = p.y + cy;
if (by < 0 || by >= CoopGame::ROWS || bx < 0 || bx >= CoopGame::COLS) {
continue;
}
occ[by * CoopGame::COLS + bx] = 1;
}
}
}
struct Eval {
double score = -std::numeric_limits<double>::infinity();
int rot = 0;
int x = 10;
};
static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) {
const auto& board = game.boardRef();
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> occ{};
for (int i = 0; i < CoopGame::COLS * CoopGame::ROWS; ++i) {
occ[i] = board[i].occupied ? 1 : 0;
}
const CoopGame::Piece cur = game.current(side);
Eval best{};
// Iterate rotations and x positions. IMPORTANT: allow x to go slightly out of bounds
// because our pieces are represented in a 4x4 mask and many rotations have leading
// empty columns. For example, placing a vertical I/J/L into column 0 often requires
// p.x == -1 or p.x == -2 so the filled cells land at bx==0.
// canPlacePieceForSide() enforces the actual half-board bounds.
for (int rot = 0; rot < 4; ++rot) {
int xmin = (side == CoopGame::PlayerSide::Right) ? 6 : -3;
int xmax = (side == CoopGame::PlayerSide::Right) ? 22 : 13;
for (int x = xmin; x <= xmax; ++x) {
CoopGame::Piece p = cur;
p.rot = rot;
p.x = x;
// If this rotation/x is illegal at the current y, try near the top spawn band.
if (!canPlacePieceForSide(board, p, side)) {
p.y = -2;
if (!canPlacePieceForSide(board, p, side)) {
continue;
}
}
p.y = dropYFor(board, p, side);
auto occ2 = occ;
applyPiece(occ2, p);
// Count completed full rows (all 20 cols) after placement.
int fullRows = 0;
for (int y = 0; y < CoopGame::ROWS; ++y) {
bool full = true;
for (int cx = 0; cx < CoopGame::COLS; ++cx) {
if (!occ2[y * CoopGame::COLS + cx]) {
full = false;
break;
}
}
if (full) {
++fullRows;
}
}
// Right-half column heights + holes + bumpiness.
std::array<int, 10> heights{};
int aggregateHeight = 0;
int holes = 0;
for (int c = 0; c < 10; ++c) {
const int bx = (side == CoopGame::PlayerSide::Right) ? (10 + c) : c;
int h = 0;
bool found = false;
for (int y = 0; y < CoopGame::ROWS; ++y) {
if (occ2[y * CoopGame::COLS + bx]) {
h = CoopGame::ROWS - y;
found = true;
// Count holes below the first filled cell.
for (int yy = y + 1; yy < CoopGame::ROWS; ++yy) {
if (!occ2[yy * CoopGame::COLS + bx]) {
++holes;
}
}
break;
}
}
heights[c] = found ? h : 0;
aggregateHeight += heights[c];
}
int bump = 0;
for (int i = 0; i < 9; ++i) {
bump += std::abs(heights[i] - heights[i + 1]);
}
// Reward sync potential: rows where the right half is full (10..19).
int sideHalfFullRows = 0;
for (int y = 0; y < CoopGame::ROWS; ++y) {
bool full = true;
int start = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
int end = (side == CoopGame::PlayerSide::Right) ? 20 : 10;
for (int bx = start; bx < end; ++bx) {
if (!occ2[y * CoopGame::COLS + bx]) {
full = false;
break;
}
}
if (full) {
++sideHalfFullRows;
}
}
// Simple heuristic:
// - Strongly prefer completed full rows
// - Prefer making the right half complete (helps cooperative clears)
// - Penalize holes and excessive height/bumpiness
double s = 0.0;
// Strongly prefer full-line clears across the whole board (rare but best).
s += static_cast<double>(fullRows) * 12000.0;
// Heavily prefer completing the player's half — make this a primary objective.
s += static_cast<double>(sideHalfFullRows) * 6000.0;
// Penalize holes and height less aggressively so completing half-rows is prioritized.
s -= static_cast<double>(holes) * 180.0;
s -= static_cast<double>(aggregateHeight) * 4.0;
s -= static_cast<double>(bump) * 10.0;
// Reduce center bias so edge placements to complete rows are not punished.
double centerTarget = (side == CoopGame::PlayerSide::Right) ? 15.0 : 4.5;
const double centerBias = -std::abs((x + 1.5) - centerTarget) * 1.0;
s += centerBias;
if (s > best.score) {
best.score = s;
best.rot = rot;
best.x = x;
}
}
}
return best;
}
} // namespace
void CoopAIController::reset() {
m_lastPieceSeq = 0;
m_hasPlan = false;
m_targetRot = 0;
m_targetX = 10;
m_moveTimerMs = 0.0;
m_moveDir = 0;
m_rotateTimerMs = 0.0;
}
void CoopAIController::computePlan(const CoopGame& game, CoopGame::PlayerSide side) {
const Eval best = evaluateBestPlacementForSide(game, side);
m_targetRot = best.rot;
m_targetX = best.x;
m_hasPlan = true;
m_moveTimerMs = 0.0;
m_moveDir = 0;
m_rotateTimerMs = 0.0;
}
void CoopAIController::update(CoopGame& game, CoopGame::PlayerSide side, double frameMs) {
const uint64_t seq = game.currentPieceSequence(side);
if (seq != m_lastPieceSeq) {
m_lastPieceSeq = seq;
m_hasPlan = false;
m_moveTimerMs = 0.0;
m_moveDir = 0;
m_rotateTimerMs = 0.0;
}
if (!m_hasPlan) {
computePlan(game, side);
}
const CoopGame::Piece cur = game.current(side);
// Clamp negative deltas (defensive; callers should pass >= 0).
const double dt = std::max(0.0, frameMs);
// Update timers.
if (m_moveTimerMs > 0.0) {
m_moveTimerMs -= dt;
if (m_moveTimerMs < 0.0) m_moveTimerMs = 0.0;
}
if (m_rotateTimerMs > 0.0) {
m_rotateTimerMs -= dt;
if (m_rotateTimerMs < 0.0) m_rotateTimerMs = 0.0;
}
// Rotate toward target first.
const int curRot = ((cur.rot % 4) + 4) % 4;
const int tgtRot = ((m_targetRot % 4) + 4) % 4;
int diff = (tgtRot - curRot + 4) % 4;
if (diff != 0) {
// Human-ish rotation rate limiting.
if (m_rotateTimerMs <= 0.0) {
const int dir = (diff == 3) ? -1 : 1;
game.rotate(side, dir);
m_rotateTimerMs = m_rotateIntervalMs;
}
// While rotating, do not also slide horizontally in the same frame.
m_moveDir = 0;
m_moveTimerMs = 0.0;
return;
}
// Move horizontally toward target.
int desiredDir = 0;
if (cur.x < m_targetX) desiredDir = +1;
else if (cur.x > m_targetX) desiredDir = -1;
if (desiredDir == 0) {
// Aligned: do nothing. Gravity controls fall speed (no AI hard drops).
m_moveDir = 0;
m_moveTimerMs = 0.0;
return;
}
// DAS/ARR-style horizontal movement pacing.
if (m_moveDir != desiredDir) {
// New direction / initial press: move immediately, then wait DAS.
game.move(side, desiredDir);
m_moveDir = desiredDir;
m_moveTimerMs = m_dasMs;
return;
}
// Holding direction: repeat every ARR once DAS has elapsed.
if (m_moveTimerMs <= 0.0) {
game.move(side, desiredDir);
m_moveTimerMs = m_arrMs;
}
}

View File

@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include "CoopGame.h"
// Minimal, lightweight AI driver for a CoopGame player side (left or right).
// It chooses a target rotation/x placement using a simple board heuristic,
// then steers the active piece toward that target at a human-like input rate.
class CoopAIController {
public:
CoopAIController() = default;
void reset();
// frameMs is the frame delta in milliseconds (same unit used across the gameplay loop).
void update(CoopGame& game, CoopGame::PlayerSide side, double frameMs);
private:
uint64_t m_lastPieceSeq = 0;
bool m_hasPlan = false;
int m_targetRot = 0;
int m_targetX = 10;
// Input pacing (ms). These intentionally mirror the defaults used for human input.
double m_dasMs = 170.0;
double m_arrMs = 40.0;
double m_rotateIntervalMs = 110.0;
// Internal timers/state for rate limiting.
double m_moveTimerMs = 0.0;
int m_moveDir = 0; // -1, 0, +1
double m_rotateTimerMs = 0.0;
void computePlan(const CoopGame& game, CoopGame::PlayerSide side);
};

View File

@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <cstring>
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<const uint8_t*>(data);
for (size_t i = 0; i < size; ++i) {
h ^= static_cast<uint64_t>(p[i]);
h *= 1099511628211ull;
}
return h;
}
template <typename T>
uint64_t hashPod(uint64_t h, const T& v) {
return fnv1a64(h, &v, sizeof(T));
}
}
void CoopGame::resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt) {
std::fill(board.begin(), board.end(), Cell{});
rowStates.fill(RowHalfState{});
completedLines.clear();
@ -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<uint32_t>(rd()));
initPlayer(right, static_cast<uint32_t>(rd()));
}
spawn(left);
spawn(right);
updateRowStates();
}
void CoopGame::reset(int startLevel_) {
resetInternal(startLevel_, std::nullopt);
}
void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) {
resetInternal(startLevel_, seed);
}
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
PlayerState& ps = player(side);
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
@ -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<uint8_t>(std::clamp(c.value, 0, 255));
h = hashPod(h, occ);
h = hashPod(h, owner);
h = hashPod(h, val);
}
auto hashPiece = [&](const Piece& p) {
const uint8_t type = static_cast<uint8_t>(p.type);
const int32_t rot = p.rot;
const int32_t x = p.x;
const int32_t y = p.y;
h = hashPod(h, type);
h = hashPod(h, rot);
h = hashPod(h, x);
h = hashPod(h, y);
};
auto hashPlayer = [&](const PlayerState& ps) {
const uint8_t side = (ps.side == PlayerSide::Left) ? 0u : 1u;
h = hashPod(h, side);
hashPiece(ps.cur);
hashPiece(ps.next);
hashPiece(ps.hold);
const uint8_t canHoldB = ps.canHold ? 1u : 0u;
const uint8_t toppedOutB = ps.toppedOut ? 1u : 0u;
h = hashPod(h, canHoldB);
h = hashPod(h, toppedOutB);
h = hashPod(h, ps.score);
h = hashPod(h, ps.lines);
h = hashPod(h, ps.level);
h = hashPod(h, ps.tetrisesMade);
h = hashPod(h, ps.currentCombo);
h = hashPod(h, ps.maxCombo);
h = hashPod(h, ps.comboCount);
h = hashPod(h, ps.pieceSeq);
const uint32_t bagSize = static_cast<uint32_t>(ps.bag.size());
h = hashPod(h, bagSize);
for (auto t : ps.bag) {
const uint8_t tt = static_cast<uint8_t>(t);
h = hashPod(h, tt);
}
};
hashPlayer(left);
hashPlayer(right);
// Session-wide counters/stats
h = hashPod(h, _score);
h = hashPod(h, _lines);
h = hashPod(h, _level);
h = hashPod(h, _tetrisesMade);
h = hashPod(h, _currentCombo);
h = hashPod(h, _maxCombo);
h = hashPod(h, _comboCount);
h = hashPod(h, startLevel);
h = hashPod(h, pieceSequence);
return h;
}
void CoopGame::move(PlayerSide side, int dx) {
PlayerState& ps = player(side);
if (gameOver || ps.toppedOut) return;
@ -307,9 +410,8 @@ void CoopGame::spawn(PlayerState& ps) {
pieceSequence++;
if (collides(ps, ps.cur)) {
ps.toppedOut = true;
if (left.toppedOut && right.toppedOut) {
gameOver = true;
}
// Cooperative mode: game ends when any player tops out.
gameOver = true;
}
}

View File

@ -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<uint32_t>& seedOpt);
std::array<Cell, COLS * ROWS> board{};
std::array<RowHalfState, ROWS> rowStates{};
PlayerState left{};

View File

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

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

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

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

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

View File

@ -1,6 +1,7 @@
#include "MenuState.h"
#include "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"
@ -9,11 +10,14 @@
#include "../audio/Audio.h"
#include "../audio/SoundEffect.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_surface.h>
#include <cstdio>
#include <algorithm>
#include <array>
#include <cmath>
#include <vector>
#include <random>
// Use dynamic logical dimensions from GlobalState instead of hardcoded values
// This allows the UI to adapt when the window is resized or goes fullscreen
@ -110,6 +114,78 @@ static void renderBackdropBlur(SDL_Renderer* renderer, const SDL_Rect& logicalVP
MenuState::MenuState(StateContext& ctx) : State(ctx) {}
void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
if (show) {
if (!coopSetupVisible && !coopSetupAnimating) {
// Avoid overlapping panels
if (aboutPanelVisible && !aboutPanelAnimating) {
aboutPanelAnimating = true;
aboutDirection = -1;
}
if (helpPanelVisible && !helpPanelAnimating) {
helpPanelAnimating = true;
helpDirection = -1;
}
if (optionsVisible && !optionsAnimating) {
optionsAnimating = true;
optionsDirection = -1;
}
if (levelPanelVisible && !levelPanelAnimating) {
levelPanelAnimating = true;
levelDirection = -1;
}
if (exitPanelVisible && !exitPanelAnimating) {
exitPanelAnimating = true;
exitDirection = -1;
if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false;
}
coopSetupAnimating = true;
coopSetupDirection = 1;
coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0;
coopSetupStep = CoopSetupStep::ChoosePartner;
coopNetworkRoleSelected = 0;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
coopSetupRectsValid = false;
selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate);
// Ensure the transition value is non-zero so render code can show
// the inline choice buttons immediately on the same frame.
if (coopSetupTransition <= 0.0) coopSetupTransition = 0.001;
}
} else {
if (coopSetupVisible && !coopSetupAnimating) {
coopSetupAnimating = true;
coopSetupDirection = -1;
coopSetupRectsValid = false;
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
// Cancel any pending network session if the coop setup is being closed.
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopSetupStep = CoopSetupStep::ChoosePartner;
// Resume menu music only when requested (ESC should pass resumeMusic=false)
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
Audio::instance().playMenuMusic();
}
}
}
}
void MenuState::showHelpPanel(bool show) {
if (show) {
if (!helpPanelVisible && !helpPanelAnimating) {
@ -204,10 +280,11 @@ void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale,
};
int startLevel = ctx.startLevelSelection ? *ctx.startLevelSelection : 0;
ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel);
const bool coopVsAI = ctx.coopVsAI ? *ctx.coopVsAI : false;
ui::BottomMenu menu = ui::buildBottomMenu(params, startLevel, coopVsAI);
const int hovered = (ctx.hoveredButton ? *ctx.hoveredButton : -1);
const double baseAlpha = 1.0;
const double baseAlpha = 1.0; // Base alpha for button rendering
// Pulse is encoded as a signed delta so PLAY can dim/brighten while focused.
const double pulseDelta = (buttonPulseAlpha - 1.0);
const double flashDelta = buttonFlash * buttonFlashAmount;
@ -225,9 +302,251 @@ void MenuState::onExit() {
if (optionsIcon) { SDL_DestroyTexture(optionsIcon); optionsIcon = nullptr; }
if (exitIcon) { SDL_DestroyTexture(exitIcon); exitIcon = nullptr; }
if (helpIcon) { SDL_DestroyTexture(helpIcon); helpIcon = nullptr; }
if (coopInfoTexture) { SDL_DestroyTexture(coopInfoTexture); coopInfoTexture = nullptr; }
}
void MenuState::handleEvent(const SDL_Event& e) {
// Text input for network IP entry (only while coop setup panel is active).
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_TEXT_INPUT) {
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
if (target.size() < 64) {
target += e.text.text;
}
return;
}
}
// Coop setup panel navigation (modal within the menu)
// Handle this FIRST and consume key events so the main menu navigation doesn't interfere.
// Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat.
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) {
// Coop setup panel navigation (modal within the menu)
switch (e.key.scancode) {
case SDL_SCANCODE_UP:
case SDL_SCANCODE_DOWN:
// Do NOT allow up/down to change anything while this panel is active
return;
case SDL_SCANCODE_ESCAPE:
// When in a nested network step, go back one step; otherwise close the panel.
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopSetupStep = CoopSetupStep::ChoosePartner;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
coopSetupStep = CoopSetupStep::NetworkChooseRole;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
return;
}
if (coopSetupStep == CoopSetupStep::NetworkWaiting) {
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopSetupStep = CoopSetupStep::NetworkChooseRole;
return;
}
showCoopSetupPanel(false, false);
return;
case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_A:
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
// 3-way selection: LOCAL / AI / NETWORK
coopSetupSelected = (coopSetupSelected + 3 - 1) % 3;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopNetworkRoleSelected = (coopNetworkRoleSelected + 2 - 1) % 2;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
return;
}
return;
case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_D:
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
coopSetupSelected = (coopSetupSelected + 1) % 3;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
return;
}
return;
case SDL_SCANCODE_BACKSPACE:
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
if (!target.empty()) target.pop_back();
return;
}
break;
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
case SDL_SCANCODE_SPACE:
{
// Existing flows (Local 2P / AI) are preserved exactly.
if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) {
const bool useAI = (coopSetupSelected == 1);
if (ctx.coopVsAI) {
*ctx.coopVsAI = useAI;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
// Close the panel without restarting menu music; gameplay will take over.
showCoopSetupPanel(false, false);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p",
coopSetupSelected,
ctx.startPlayTransition ? 1 : 0,
(void*)ctx.stateManager);
if (ctx.startPlayTransition) {
ctx.startPlayTransition();
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
}
return;
}
// Network flow (new): choose host/join, confirm connection before starting.
if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) {
coopSetupStep = CoopSetupStep::NetworkChooseRole;
coopNetworkRoleSelected = 0;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
// First, let the user enter the address (bind for host, remote for join).
coopSetupStep = CoopSetupStep::NetworkEnterAddress;
coopNetworkStatusText.clear();
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StartTextInput(focusWin);
}
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopNetworkSession = std::make_unique<NetSession>();
const uint16_t port = coopNetworkPort;
bool ok = false;
if (coopNetworkRoleSelected == 0) {
const std::string bindIp = coopNetworkBindAddress;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST start bind=%s port=%u", bindIp.c_str(), (unsigned)port);
ok = coopNetworkSession->host(bindIp, port);
coopNetworkStatusText = ok ? "WAITING FOR PLAYER..." : ("HOST FAILED: " + coopNetworkSession->lastError());
} else {
const std::string joinIp = coopNetworkJoinAddress.empty() ? std::string("127.0.0.1") : coopNetworkJoinAddress;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] JOIN start remote=%s port=%u", joinIp.c_str(), (unsigned)port);
ok = coopNetworkSession->join(joinIp, port);
coopNetworkStatusText = ok ? "CONNECTING..." : ("JOIN FAILED: " + coopNetworkSession->lastError());
}
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
if (ok) {
coopSetupStep = CoopSetupStep::NetworkWaiting;
} else {
// Stay on role choice screen so user can back out.
coopNetworkSession.reset();
coopSetupStep = CoopSetupStep::NetworkChooseRole;
}
return;
}
// While waiting for connection, Enter does nothing.
return;
}
default:
// Allow other keys, but don't let them affect the main menu while coop is open.
return;
}
}
// Mouse input for COOP setup panel or inline coop buttons
if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) {
if (coopSetupRectsValid) {
// While the coop submenu is active (animating or visible) we disallow
// mouse interaction — only keyboard LEFT/RIGHT/ESC is permitted.
if (coopSetupAnimating || coopSetupVisible) {
return;
}
const float mx = static_cast<float>(e.button.x);
const float my = static_cast<float>(e.button.y);
if (mx >= lastLogicalVP.x && my >= lastLogicalVP.y && mx <= (lastLogicalVP.x + lastLogicalVP.w) && my <= (lastLogicalVP.y + lastLogicalVP.h)) {
const float lx = (mx - lastLogicalVP.x) / std::max(0.0001f, lastLogicalScale);
const float ly = (my - lastLogicalVP.y) / std::max(0.0001f, lastLogicalScale);
auto hit = [&](const SDL_FRect& r) {
return lx >= r.x && lx <= (r.x + r.w) && ly >= r.y && ly <= (r.y + r.h);
};
int chosen = -1;
if (hit(coopSetupBtnRects[0])) chosen = 0;
else if (hit(coopSetupBtnRects[1])) chosen = 1;
if (chosen != -1) {
coopSetupSelected = chosen;
const bool useAI = (coopSetupSelected == 1);
if (ctx.coopVsAI) {
*ctx.coopVsAI = useAI;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
showCoopSetupPanel(false);
if (ctx.startPlayTransition) {
ctx.startPlayTransition();
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
}
return;
}
}
}
}
// Keyboard navigation for menu buttons
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
// When the player uses the keyboard, don't let an old mouse hover keep focus on a button.
@ -290,8 +609,21 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
return;
case SDL_SCANCODE_ESCAPE:
// Close HUD
exitPanelAnimating = true; exitDirection = -1;
showCoopSetupPanel(false, true);
// Cannot print std::function as a pointer; print presence (1/0) instead
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop ENTER pressed, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager);
if (ctx.startPlayTransition) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: calling startPlayTransition");
ctx.startPlayTransition();
} else {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: startPlayTransition is null");
}
if (ctx.stateManager) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: setting AppState::Playing on stateManager");
ctx.stateManager->setState(AppState::Playing);
} else {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: stateManager is null");
}
if (ctx.showExitConfirmPopup) *ctx.showExitConfirmPopup = false;
return;
case SDL_SCANCODE_PAGEDOWN:
@ -457,6 +789,49 @@ void MenuState::handleEvent(const SDL_Event& e) {
return;
}
// Coop setup panel navigation (modal within the menu)
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0) {
switch (e.key.scancode) {
case SDL_SCANCODE_LEFT:
coopSetupSelected = 0;
buttonFlash = 1.0;
return;
case SDL_SCANCODE_RIGHT:
coopSetupSelected = 1;
buttonFlash = 1.0;
return;
// Explicitly consume Up/Down so main menu navigation doesn't trigger
case SDL_SCANCODE_UP:
case SDL_SCANCODE_DOWN:
return;
case SDL_SCANCODE_ESCAPE:
// Close coop panel without restarting music
showCoopSetupPanel(false, false);
return;
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
case SDL_SCANCODE_SPACE:
{
const bool useAI = (coopSetupSelected == 1);
if (ctx.coopVsAI) {
*ctx.coopVsAI = useAI;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
showCoopSetupPanel(false, false);
triggerPlay();
return;
}
default:
break;
}
}
switch (e.key.scancode) {
case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_UP:
@ -489,15 +864,8 @@ void MenuState::handleEvent(const SDL_Event& e) {
triggerPlay();
break;
case 1:
// Cooperative play
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);
}
triggerPlay();
// Cooperative play: open setup panel (2P vs AI)
showCoopSetupPanel(true);
break;
case 2:
// Start challenge run at level 1
@ -566,6 +934,10 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
break;
case SDL_SCANCODE_ESCAPE:
if (coopSetupVisible && !coopSetupAnimating) {
showCoopSetupPanel(false, false);
return;
}
// If options panel is visible, hide it first.
if (optionsVisible && !optionsAnimating) {
optionsAnimating = true;
@ -588,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
@ -665,6 +1046,21 @@ void MenuState::update(double frameMs) {
}
}
// Advance coop setup panel animation if active
if (coopSetupAnimating) {
double delta = (frameMs / coopSetupTransitionDurationMs) * static_cast<double>(coopSetupDirection);
coopSetupTransition += delta;
if (coopSetupTransition >= 1.0) {
coopSetupTransition = 1.0;
coopSetupVisible = true;
coopSetupAnimating = false;
} else if (coopSetupTransition <= 0.0) {
coopSetupTransition = 0.0;
coopSetupVisible = false;
coopSetupAnimating = false;
}
}
// Animate level selection highlight position toward the selected cell center
if (levelTransition > 0.0 && (lastLogicalScale > 0.0f)) {
// Recompute same grid geometry used in render to find target center
@ -790,6 +1186,8 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
const float moveAmount = 420.0f; // increased so lower score rows slide further up
// Compute eased transition and delta to shift highscores when either options, level, or exit HUD is shown.
// Exclude coopSetupTransition from the highscores slide so opening the
// COOPERATE setup does not shift the highscores panel upward.
float combinedTransition = static_cast<float>(std::max(
std::max(std::max(optionsTransition, levelTransition), exitTransition),
std::max(helpTransition, aboutTransition)
@ -823,22 +1221,35 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
}
}
// Small TOP PLAYER label under the logo
const std::string smallTitle = "TOP PLAYER";
// Small label under the logo — show "COOPERATE" when coop setup is active
const std::string smallTitle = (coopSetupAnimating || coopSetupVisible) ? "COOPERATE" : "TOP PLAYER";
float titleScale = 0.9f;
int tW = 0, tH = 0;
useFont->measure(smallTitle, titleScale, tW, tH);
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<ScoreEntry> 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<ScoreEntry> filtered;
filtered.reserve(hs.size());
@ -848,7 +1259,9 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
size_t maxDisplay = std::min(filtered.size(), size_t(10)); // display only top 10
// Draw highscores as an inline HUD-like panel (no opaque box), matching Options/Level/Exit style
if (useFont) {
// Keep highscores visible while the coop setup is animating; hide them only
// once the coop setup is fully visible so the buttons can appear afterward.
if (useFont && !coopSetupVisible) {
const float panelW = (wantedType == "cooperate") ? std::min(920.0f, LOGICAL_W * 0.92f) : std::min(780.0f, LOGICAL_W * 0.85f);
const float panelH = 36.0f + maxDisplay * 36.0f; // header + rows
// Shift the entire highscores panel slightly left (~1.5% of logical width)
@ -1112,6 +1525,286 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
}
}
// Inline COOP choice buttons: when COOPERATE is selected show two large
// choice buttons in the highscores panel area (top of the screen).
// coopSetupRectsValid is cleared each frame and set to true when buttons are drawn
coopSetupRectsValid = false;
// Draw the inline COOP choice buttons as soon as the coop setup starts
// animating or is visible. Highscores are no longer slid upward when
// the setup opens, so the buttons can show immediately.
if (coopSetupAnimating || coopSetupVisible) {
// Recompute panel geometry matching highscores layout above so buttons
// appear centered inside the same visual area.
const float panelW = std::min(920.0f, LOGICAL_W * 0.92f);
const float panelShift = LOGICAL_W * 0.015f;
const float panelBaseX = (LOGICAL_W - panelW) * 0.5f + contentOffsetX - panelShift;
const float panelH = 36.0f + maxDisplay * 36.0f; // same as highscores panel
// Highscores are animated upward by `panelDelta` while opening the coop setup.
// We want the choice buttons to appear *after* that scroll, in the original
// highscores area (not sliding offscreen with the scores).
const float panelBaseY = scoresStartY - 20.0f;
// Choice buttons (partner selection) and nested network host/join UI
const float btnH2 = 60.0f;
const float gap = 34.0f;
const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f);
const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f;
// Shift the image and buttons slightly for layout balance
const float shiftX = 20.0f;
const float bx = panelBaseX + (panelW - totalChoiceW) * 0.5f + shiftX;
// Move the buttons up by ~80px to sit closer under the logo
const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f;
coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 };
coopSetupBtnRects[1] = SDL_FRect{ bx + (btnW2 + gap), by, btnW2, btnH2 };
coopSetupBtnRects[2] = SDL_FRect{ bx + (btnW2 + gap) * 2.0f, by, btnW2, btnH2 };
coopSetupRectsValid = true;
SDL_Color bg{ 24, 36, 52, 220 };
SDL_Color border{ 110, 200, 255, 220 };
// Load coop info image once when the coop setup is first shown
if (!coopInfoTexture) {
const std::string resolved = AssetPath::resolveImagePath("assets/images/cooperate_info.png");
if (!resolved.empty()) {
SDL_Surface* surf = IMG_Load(resolved.c_str());
if (surf) {
// Save dimensions from surface then create texture
coopInfoTexW = surf->w;
coopInfoTexH = surf->h;
coopInfoTexture = SDL_CreateTextureFromSurface(renderer, surf);
SDL_DestroySurface(surf);
} else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "MenuState: failed to load %s: %s", resolved.c_str(), SDL_GetError());
}
}
}
// If the image loaded, render it centered above the choice buttons
// Compute fade alpha from the coop transition so it can be used for image, text and buttons
float alphaFactor = static_cast<float>(coopSetupTransition);
if (alphaFactor < 0.0f) alphaFactor = 0.0f;
if (alphaFactor > 1.0f) alphaFactor = 1.0f;
// Compute coop info image placement (draw as background for both ChoosePartner and Network steps)
float imgX = 0.0f, imgY = 0.0f, targetW = 0.0f, targetH = 0.0f;
bool hasCoopImg = false;
if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) {
float totalW = totalChoiceW;
// Keep coop info image slightly smaller than the button row.
// Use a modest scale so it doesn't dominate the UI.
float maxImgW = totalW * 0.65f;
targetW = std::min(maxImgW, static_cast<float>(coopInfoTexW));
float scale = targetW / static_cast<float>(coopInfoTexW);
targetH = static_cast<float>(coopInfoTexH) * scale;
imgX = bx + (totalW - targetW) * 0.5f;
imgY = by - targetH - 8.0f; // keep the small gap above buttons
float minY = panelBaseY + 6.0f;
if (imgY < minY) imgY = minY;
SDL_FRect dst{ imgX, imgY, targetW, targetH };
SDL_SetTextureBlendMode(coopInfoTexture, SDL_BLENDMODE_BLEND);
// Make the coop info image slightly transparent scaled by transition
SDL_SetTextureAlphaMod(coopInfoTexture, static_cast<Uint8>(std::round(200.0f * alphaFactor)));
SDL_RenderTexture(renderer, coopInfoTexture, nullptr, &dst);
hasCoopImg = true;
// Only draw the instructional overlay text when choosing partner.
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont;
if (f) {
const float pad = 38.0f;
float textX = panelBaseX + pad;
// Position the text over the lower portion of the image (overlay)
// Move the block upward by ~150px to match UI request
float textY = imgY + targetH - std::min(80.0f, targetH * 0.35f) - 150.0f;
// Bulleted list (measure sample line height first)
const std::vector<std::string> bullets = {
"The playfield is shared between two players",
"Each player controls one half of the grid",
"A line clears only when both halves are filled",
"Timing and coordination are essential"
};
float bulletScale = 0.78f;
SDL_Color bulletCol{200,220,230,220};
bulletCol.a = static_cast<Uint8>(std::round(bulletCol.a * alphaFactor));
int sampleLW = 0, sampleLH = 0;
f->measure(bullets[0], bulletScale, sampleLW, sampleLH);
// Header: move it up by one sample row so it sits higher
const std::string header = "* HOW TO PLAY COOPERATE MODE *";
float headerScale = 0.95f;
int hW=0, hH=0; f->measure(header, headerScale, hW, hH);
float hx = panelBaseX + (panelW - static_cast<float>(hW)) * 0.5f + 40.0f; // nudge header right by 40px
float headerY = textY - static_cast<float>(sampleLH);
SDL_Color headerCol = SDL_Color{220,240,255,230}; headerCol.a = static_cast<Uint8>(std::round(headerCol.a * alphaFactor));
f->draw(renderer, hx, headerY, header, headerScale, headerCol);
// Start body text slightly below header
textY = headerY + static_cast<float>(hH) + 8.0f;
// Shift non-header text to the right by 100px and down by 20px
float bulletX = textX + 200.0f;
textY += 20.0f;
for (const auto &line : bullets) {
std::string withBullet = std::string("") + line;
f->draw(renderer, bulletX, textY, withBullet, bulletScale, bulletCol);
int lw=0, lH=0; f->measure(withBullet, bulletScale, lw, lH);
textY += static_cast<float>(lH) + 6.0f;
}
// GOAL section (aligned with shifted bullets)
textY += 6.0f;
std::string goalTitle = "GOAL:";
SDL_Color goalTitleCol = SDL_Color{255,215,80,230}; goalTitleCol.a = static_cast<Uint8>(std::round(goalTitleCol.a * alphaFactor));
f->draw(renderer, bulletX, textY, goalTitle, 0.88f, goalTitleCol);
int gW=0, gH=0; f->measure(goalTitle, 0.88f, gW, gH);
float goalX = bulletX + static_cast<float>(gW) + 10.0f;
std::string goalText = "Clear lines together and achieve the highest TEAM SCORE";
SDL_Color goalTextCol = SDL_Color{220,240,255,220}; goalTextCol.a = static_cast<Uint8>(std::round(goalTextCol.a * alphaFactor));
f->draw(renderer, goalX, textY, goalText, 0.86f, goalTextCol);
}
}
}
// Delay + eased fade specifically for the two coop buttons so they appear after the image/text.
const float btnDelay = 0.25f; // fraction of transition to wait before buttons start fading
float rawBtn = (alphaFactor - btnDelay) / (1.0f - btnDelay);
rawBtn = std::clamp(rawBtn, 0.0f, 1.0f);
// ease-in (squared) for a slower, smoother fade
float buttonFade = rawBtn * rawBtn;
SDL_Color bgA = bg; bgA.a = static_cast<Uint8>(std::round(bgA.a * buttonFade));
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(std::round(borderA.a * buttonFade));
// Step 1: choose partner mode
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[0].x + btnW2 * 0.5f,
coopSetupBtnRects[0].y + btnH2 * 0.5f,
btnW2, btnH2,
"LOCAL CO-OP",
false,
coopSetupSelected == 0,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[1].x + btnW2 * 0.5f,
coopSetupBtnRects[1].y + btnH2 * 0.5f,
btnW2, btnH2,
"AI PARTNER",
false,
coopSetupSelected == 1,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[2].x + btnW2 * 0.5f,
coopSetupBtnRects[2].y + btnH2 * 0.5f,
btnW2, btnH2,
"2 PLAYER (NET)",
false,
coopSetupSelected == 2,
bgA,
borderA,
false,
nullptr);
}
// Step 2: network host/join selection and address entry
if (coopSetupStep == CoopSetupStep::NetworkChooseRole || coopSetupStep == CoopSetupStep::NetworkEnterAddress || coopSetupStep == CoopSetupStep::NetworkWaiting) {
// Draw two buttons centered under the main row.
const float roleBtnW = std::min(280.0f, panelW * 0.30f);
const float roleGap = 48.0f;
const float roleTotalW = roleBtnW * 2.0f + roleGap;
const float roleX = panelBaseX + (panelW - roleTotalW) * 0.5f + shiftX;
// Move the host/join buttons down from the previous higher position.
// Shift down by one button height plus half a button (effectively lower them):
const float roleY = by + (btnH2 * 0.5f) - 18.0f;
SDL_FRect hostRect{ roleX, roleY, roleBtnW, btnH2 };
SDL_FRect joinRect{ roleX + roleBtnW + roleGap, roleY, roleBtnW, btnH2 };
UIRenderer::drawButton(renderer, ctx.pixelFont,
hostRect.x + roleBtnW * 0.5f,
hostRect.y + btnH2 * 0.5f,
roleBtnW,
btnH2,
"HOST GAME",
false,
coopNetworkRoleSelected == 0,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
joinRect.x + roleBtnW * 0.5f,
joinRect.y + btnH2 * 0.5f,
roleBtnW,
btnH2,
"JOIN GAME",
false,
coopNetworkRoleSelected == 1,
bgA,
borderA,
false,
nullptr);
FontAtlas* f = ctx.font ? ctx.font : ctx.pixelFont;
if (f) {
SDL_Color infoCol{200, 220, 230, static_cast<Uint8>(std::round(220.0f * buttonFade))};
// Draw connection info on separate lines and shift right by ~200px
char portLine[64];
std::snprintf(portLine, sizeof(portLine), "PORT %u", (unsigned)coopNetworkPort);
char hostLine[128];
std::snprintf(hostLine, sizeof(hostLine), "HOST IP %s", coopNetworkBindAddress.c_str());
char joinLine[128];
std::snprintf(joinLine, sizeof(joinLine), "JOIN IP %s", coopNetworkJoinAddress.c_str());
const float textShiftX = 200.0f;
const float textX = panelBaseX + 60.0f + textShiftX;
const float endpointY = (hasCoopImg ? (imgY + targetH * 0.62f) : (roleY + btnH2 + 12.0f));
const float lineSpacing = 28.0f;
// Show only the minimal info needed for the selected role.
f->draw(renderer, textX, endpointY, portLine, 0.90f, infoCol);
if (coopNetworkRoleSelected == 0) {
// Host: show bind address only
f->draw(renderer, textX, endpointY + lineSpacing, hostLine, 0.90f, infoCol);
} else {
// Client: show join target only
f->draw(renderer, textX, endpointY + lineSpacing, joinLine, 0.90f, infoCol);
}
float hintY = endpointY + lineSpacing * 2.0f + 6.0f;
// Bottom helper prompt: show a compact instruction under the image window
float bottomY = hasCoopImg ? (imgY + targetH + 18.0f) : (hintY + 36.0f);
SDL_Color bottomCol{180,200,210,200};
if (coopNetworkRoleSelected == 0) {
f->draw(renderer, textX, bottomY, "HOST: press ENTER to edit bind IP, then press ENTER to confirm", 0.82f, bottomCol);
} else {
f->draw(renderer, textX, bottomY, "JOIN: press ENTER to type server IP, then press ENTER to connect", 0.82f, bottomCol);
}
if (coopSetupStep == CoopSetupStep::NetworkWaiting && !coopNetworkStatusText.empty()) {
SDL_Color statusCol{255, 215, 80, static_cast<Uint8>(std::round(240.0f * buttonFade))};
f->draw(renderer, textX, hintY, coopNetworkStatusText, 1.00f, statusCol);
} else if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
const char* label = (coopNetworkRoleSelected == 0) ? "TYPE HOST IP (BIND) THEN ENTER" : "TYPE JOIN IP THEN ENTER";
f->draw(renderer, textX, hintY, label, 0.82f, hintCol);
} else {
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
f->draw(renderer, textX, hintY, "PRESS ENTER TO EDIT/CONFIRM ESC TO GO BACK", 0.82f, hintCol);
}
}
}
}
// NOTE: slide-up COOP panel intentionally removed. Only the inline
// highscores-area choice buttons are shown when coop setup is active.
// Inline exit HUD (no opaque background) - slides into the highscores area
if (exitTransition > 0.0) {
float easedE = static_cast<float>(exitTransition);
@ -1465,4 +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<uint32_t>(rd());
if (seed == 0u) seed = 1u;
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
NetSession::Handshake hs{ seed, 0u, startLevel };
if (coopNetworkSession->sendHandshake(hs)) {
coopNetworkHandshakeSent = true;
ctx.coopNetRngSeed = seed;
coopNetworkStatusText = "CONNECTED";
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST handshake sent seed=%u level=%u", seed, (unsigned)startLevel);
} else {
coopNetworkStatusText = "HANDSHAKE FAILED";
}
}
// Client waits for handshake.
if (coopNetworkRoleSelected == 1) {
auto hs = coopNetworkSession->takeReceivedHandshake();
if (hs.has_value()) {
coopNetworkStatusText = "CONNECTED";
coopNetworkHandshakeSent = true;
ctx.coopNetRngSeed = hs->rngSeed;
if (ctx.startLevelSelection) {
*ctx.startLevelSelection = static_cast<int>(hs->startLevel);
}
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] CLIENT handshake recv seed=%u level=%u", hs->rngSeed, (unsigned)hs->startLevel);
} else {
coopNetworkStatusText = "CONNECTED - WAITING FOR HOST...";
}
}
// Confirmed connection => start COOPERATE (network) session.
// Note: gameplay/network input injection is implemented separately.
if (coopNetworkHandshakeSent) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET COOP] START gameplay (role=%s localIsLeft=%d seed=%u level=%u)",
(coopNetworkRoleSelected == 0 ? "HOST" : "CLIENT"),
(coopNetworkRoleSelected == 0 ? 1 : 0),
(unsigned)ctx.coopNetRngSeed,
(unsigned)(ctx.startLevelSelection ? *ctx.startLevelSelection : 0));
// Hand off the session to gameplay.
if (ctx.coopNetSession) {
ctx.coopNetSession->shutdown();
ctx.coopNetSession.reset();
}
ctx.coopNetEnabled = true;
ctx.coopNetIsHost = (coopNetworkRoleSelected == 0);
ctx.coopNetLocalIsLeft = (coopNetworkRoleSelected == 0);
ctx.coopNetTick = 0;
ctx.coopNetPendingButtons = 0;
ctx.coopNetDesyncDetected = false;
const uint32_t seed = (ctx.coopNetRngSeed == 0u) ? 1u : ctx.coopNetRngSeed;
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
if (ctx.coopVsAI) {
*ctx.coopVsAI = false;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
// Deterministic reset for network coop.
ctx.coopGame->resetDeterministic(startLevel, seed);
}
// Transfer ownership of the active session.
ctx.coopNetSession = std::move(coopNetworkSession);
// Close the panel without restarting menu music; gameplay will take over.
showCoopSetupPanel(false, false);
// For network lockstep, do NOT run the menu->play countdown/fade.
// Any local countdown introduces drift and stalls.
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
if (ctx.game) ctx.game->setPaused(false);
if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
} else if (ctx.startPlayTransition) {
// Fallback if state manager is unavailable.
ctx.startPlayTransition();
}
}
}
}
}
}

View File

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

View File

@ -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 <SDL3/SDL.h>
// 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,27 +175,76 @@ void PlayingState::handleEvent(const SDL_Event& e) {
}
if (coopActive && ctx.coopGame) {
// Player 1 (left): A/D move via DAS in ApplicationManager; here handle rotations/hold/hard-drop
if (e.key.scancode == SDL_SCANCODE_W) {
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
return;
}
if (e.key.scancode == SDL_SCANCODE_Q) {
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1);
return;
}
// Hard drop (left): keep LSHIFT, also allow E for convenience.
if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
return;
}
if (e.key.scancode == SDL_SCANCODE_LCTRL) {
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
return;
// 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.
}
// Player 2 (right): arrow keys move via DAS; rotations/hold/hard-drop here
const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
// Player 1 (left): when AI is enabled it controls the left side so
// ignore direct player input for the left board.
if (coopAIEnabled) {
// Left side controlled by AI; skip left-side input handling here.
} else {
// Player 1 manual controls (left side)
if (e.key.scancode == SDL_SCANCODE_W) {
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
return;
}
if (e.key.scancode == SDL_SCANCODE_Q) {
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1);
return;
}
// Hard drop (left): keep LSHIFT, also allow E for convenience.
if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
return;
}
if (e.key.scancode == SDL_SCANCODE_LCTRL) {
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
return;
}
}
if (e.key.scancode == SDL_SCANCODE_UP) {
bool upIsCW = Settings::instance().isUpRotateClockwise();
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
@ -183,6 +258,10 @@ void PlayingState::handleEvent(const SDL_Event& e) {
if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
if (coopAIEnabled) {
// Mirror human-initiated hard-drop to AI on left
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
}
return;
}
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
@ -303,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);
@ -411,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(
@ -456,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,

View File

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

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

@ -0,0 +1,389 @@
// VideoState.cpp
#include "VideoState.h"
#include "../video/VideoPlayer.h"
#include "../audio/Audio.h"
#include "../core/state/StateManager.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <cstdint>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
VideoState::VideoState(StateContext& ctx)
: State(ctx)
, m_player(std::make_unique<VideoPlayer>())
{
}
VideoState::~VideoState() {
onExit();
}
bool VideoState::begin(SDL_Renderer* renderer, const std::string& path) {
m_path = path;
if (!m_player) {
m_player = std::make_unique<VideoPlayer>();
}
if (!m_player->open(m_path, renderer)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to open intro video: %s", m_path.c_str());
return false;
}
if (!m_player->decodeFirstFrame()) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to decode first frame: %s", m_path.c_str());
// Still allow entering; we will likely render black.
}
return true;
}
void VideoState::onEnter() {
m_phase = Phase::FadeInFirstFrame;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 1.0f;
m_audioDecoded.store(false);
m_audioDecodeFailed.store(false);
m_audioStarted = false;
m_audioPcm.clear();
m_audioRate = 44100;
m_audioChannels = 2;
// Decode audio in the background during fade-in.
m_audioThread = std::make_unique<std::jthread>([this](std::stop_token st) {
(void)st;
std::vector<int16_t> pcm;
int rate = 44100;
int channels = 2;
const bool ok = decodeAudioPcm16Stereo44100(m_path, pcm, rate, channels);
if (!ok) {
m_audioDecodeFailed.store(true);
m_audioDecoded.store(true, std::memory_order_release);
return;
}
// Transfer results.
m_audioRate = rate;
m_audioChannels = channels;
m_audioPcm = std::move(pcm);
m_audioDecoded.store(true, std::memory_order_release);
});
}
void VideoState::onExit() {
stopAudio();
if (m_audioThread) {
// Request stop and join.
m_audioThread.reset();
}
}
void VideoState::handleEvent(const SDL_Event& e) {
(void)e;
}
void VideoState::startAudioIfReady() {
if (m_audioStarted) return;
if (!m_audioDecoded.load(std::memory_order_acquire)) return;
if (m_audioDecodeFailed.load()) return;
if (m_audioPcm.empty()) return;
// Use the existing audio output path (same device as music/SFX).
Audio::instance().playSfx(m_audioPcm, m_audioChannels, m_audioRate, 1.0f);
m_audioStarted = true;
}
void VideoState::stopAudio() {
// We currently feed intro audio as an SFX buffer into the mixer.
// It will naturally end; no explicit stop is required.
}
void VideoState::update(double frameMs) {
switch (m_phase) {
case Phase::FadeInFirstFrame: {
m_phaseClockMs += frameMs;
const float t = (FADE_IN_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_IN_MS, 0.0, 1.0)) : 1.0f;
m_blackOverlayAlpha = 1.0f - t;
if (t >= 1.0f) {
m_phase = Phase::Playing;
m_phaseClockMs = 0.0;
if (m_player) {
m_player->start();
}
startAudioIfReady();
}
break;
}
case Phase::Playing: {
startAudioIfReady();
if (m_player) {
m_player->update(frameMs);
if (m_player->isFinished()) {
m_phase = Phase::FadeOutToBlack;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 0.0f;
}
} else {
m_phase = Phase::FadeOutToBlack;
m_phaseClockMs = 0.0;
m_blackOverlayAlpha = 0.0f;
}
break;
}
case Phase::FadeOutToBlack: {
m_phaseClockMs += frameMs;
const float t = (FADE_OUT_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_OUT_MS, 0.0, 1.0)) : 1.0f;
m_blackOverlayAlpha = t;
if (t >= 1.0f) {
// Switch to MAIN (Menu) with a fade-in from black.
if (ctx.startupFadeAlpha) {
*ctx.startupFadeAlpha = 1.0f;
}
if (ctx.startupFadeActive) {
*ctx.startupFadeActive = true;
}
if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Menu);
}
m_phase = Phase::Done;
}
break;
}
case Phase::Done:
default:
break;
}
}
void VideoState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
(void)logicalScale;
(void)logicalVP;
if (!renderer) return;
int winW = 0, winH = 0;
SDL_GetRenderOutputSize(renderer, &winW, &winH);
// Draw video fullscreen if available.
if (m_player && m_player->isTextureReady()) {
SDL_SetRenderViewport(renderer, nullptr);
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
m_player->render(renderer, winW, winH);
} else {
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_FRect r{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &r);
}
// Apply fade overlay (black).
if (m_blackOverlayAlpha > 0.0f) {
const Uint8 a = (Uint8)std::clamp((int)std::lround(m_blackOverlayAlpha * 255.0f), 0, 255);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
SDL_RenderFillRect(renderer, &full);
}
}
bool VideoState::decodeAudioPcm16Stereo44100(
const std::string& path,
std::vector<int16_t>& outPcm,
int& outRate,
int& outChannels
) {
outPcm.clear();
outRate = 44100;
outChannels = 2;
AVFormatContext* fmt = nullptr;
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) {
return false;
}
if (avformat_find_stream_info(fmt, nullptr) < 0) {
avformat_close_input(&fmt);
return false;
}
int audioStream = -1;
for (unsigned i = 0; i < fmt->nb_streams; ++i) {
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
audioStream = (int)i;
break;
}
}
if (audioStream < 0) {
avformat_close_input(&fmt);
return false;
}
AVCodecParameters* codecpar = fmt->streams[audioStream]->codecpar;
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
if (!codec) {
avformat_close_input(&fmt);
return false;
}
AVCodecContext* dec = avcodec_alloc_context3(codec);
if (!dec) {
avformat_close_input(&fmt);
return false;
}
if (avcodec_parameters_to_context(dec, codecpar) < 0) {
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
if (avcodec_open2(dec, codec, nullptr) < 0) {
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
AVChannelLayout outLayout{};
av_channel_layout_default(&outLayout, 2);
AVChannelLayout inLayout{};
if (av_channel_layout_copy(&inLayout, &dec->ch_layout) < 0 || inLayout.nb_channels <= 0) {
av_channel_layout_uninit(&inLayout);
av_channel_layout_default(&inLayout, 2);
}
SwrContext* swr = nullptr;
if (swr_alloc_set_opts2(
&swr,
&outLayout,
AV_SAMPLE_FMT_S16,
44100,
&inLayout,
dec->sample_fmt,
dec->sample_rate,
0,
nullptr
) < 0) {
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
if (swr_init(swr) < 0) {
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
AVPacket* pkt = av_packet_alloc();
AVFrame* frame = av_frame_alloc();
if (!pkt || !frame) {
if (pkt) av_packet_free(&pkt);
if (frame) av_frame_free(&frame);
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
return false;
}
const int outRateConst = 44100;
const int outCh = 2;
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index != audioStream) {
av_packet_unref(pkt);
continue;
}
if (avcodec_send_packet(dec, pkt) < 0) {
av_packet_unref(pkt);
continue;
}
av_packet_unref(pkt);
while (true) {
const int rr = avcodec_receive_frame(dec, frame);
if (rr == AVERROR(EAGAIN) || rr == AVERROR_EOF) {
break;
}
if (rr < 0) {
break;
}
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
std::vector<uint8_t> outBytes;
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
uint8_t* outData[1] = { outBytes.data() };
const uint8_t** inData = (const uint8_t**)frame->data;
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
if (converted > 0) {
const size_t samplesOut = (size_t)converted * (size_t)outCh;
const int16_t* asS16 = (const int16_t*)outBytes.data();
const size_t oldSize = outPcm.size();
outPcm.resize(oldSize + samplesOut);
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
}
av_frame_unref(frame);
}
}
// Flush decoder
avcodec_send_packet(dec, nullptr);
while (avcodec_receive_frame(dec, frame) >= 0) {
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
std::vector<uint8_t> outBytes;
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
uint8_t* outData[1] = { outBytes.data() };
const uint8_t** inData = (const uint8_t**)frame->data;
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
if (converted > 0) {
const size_t samplesOut = (size_t)converted * (size_t)outCh;
const int16_t* asS16 = (const int16_t*)outBytes.data();
const size_t oldSize = outPcm.size();
outPcm.resize(oldSize + samplesOut);
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
}
av_frame_unref(frame);
}
av_frame_free(&frame);
av_packet_free(&pkt);
swr_free(&swr);
av_channel_layout_uninit(&inLayout);
av_channel_layout_uninit(&outLayout);
avcodec_free_context(&dec);
avformat_close_input(&fmt);
outRate = outRateConst;
outChannels = outCh;
return !outPcm.empty();
}

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

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

View File

@ -13,7 +13,7 @@ static bool pointInRect(const SDL_FRect& r, float x, float y) {
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
}
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI) {
BottomMenu menu{};
auto rects = computeMenuButtonRects(params);
@ -22,6 +22,7 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
// Always show a neutral "COOPERATE" label (remove per-mode suffixes)
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };

View File

@ -35,7 +35,7 @@ struct BottomMenu {
std::array<Button, MENU_BTN_COUNT> buttons{};
};
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel);
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI);
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
// hoveredIndex: -1..7

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

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

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

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

View File

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