added basic network play
This commit is contained in:
@ -84,8 +84,9 @@ Do not introduce new build steps unless required.
|
|||||||
- Rendering
|
- Rendering
|
||||||
- Input
|
- Input
|
||||||
- Game simulation
|
- Game simulation
|
||||||
- Networking must run in a **separate thread**
|
- Networking must be **non-blocking** from the SDL main loop
|
||||||
- SDL main loop must **never block** on networking
|
- 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
|
- Cross-thread communication via queues or buffers only
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -113,7 +114,8 @@ Never hardcode machine-specific paths.
|
|||||||
|
|
||||||
## Networking (COOPERATE Network Mode)
|
## Networking (COOPERATE Network Mode)
|
||||||
|
|
||||||
Follow `network_cooperate_multiplayer.md`.
|
Follow `docs/ai/cooperate_network.md`.
|
||||||
|
If `network_cooperate_multiplayer.md` exists, keep it consistent with the canonical doc.
|
||||||
|
|
||||||
Mandatory model:
|
Mandatory model:
|
||||||
- **Input lockstep**
|
- **Input lockstep**
|
||||||
|
|||||||
@ -28,6 +28,7 @@ find_package(SDL3_ttf CONFIG REQUIRED)
|
|||||||
find_package(SDL3_image CONFIG REQUIRED)
|
find_package(SDL3_image CONFIG REQUIRED)
|
||||||
find_package(cpr CONFIG REQUIRED)
|
find_package(cpr CONFIG REQUIRED)
|
||||||
find_package(nlohmann_json CONFIG REQUIRED)
|
find_package(nlohmann_json CONFIG REQUIRED)
|
||||||
|
find_package(unofficial-enet CONFIG REQUIRED)
|
||||||
|
|
||||||
set(TETRIS_SOURCES
|
set(TETRIS_SOURCES
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
@ -46,6 +47,7 @@ set(TETRIS_SOURCES
|
|||||||
src/graphics/renderers/RenderManager.cpp
|
src/graphics/renderers/RenderManager.cpp
|
||||||
src/persistence/Scores.cpp
|
src/persistence/Scores.cpp
|
||||||
src/network/supabase_client.cpp
|
src/network/supabase_client.cpp
|
||||||
|
src/network/NetSession.cpp
|
||||||
src/graphics/effects/Starfield.cpp
|
src/graphics/effects/Starfield.cpp
|
||||||
src/graphics/effects/Starfield3D.cpp
|
src/graphics/effects/Starfield3D.cpp
|
||||||
src/graphics/effects/SpaceWarp.cpp
|
src/graphics/effects/SpaceWarp.cpp
|
||||||
@ -160,10 +162,10 @@ if(APPLE)
|
|||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json)
|
target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet)
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid)
|
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
|
||||||
endif()
|
endif()
|
||||||
if(APPLE)
|
if(APPLE)
|
||||||
# Needed for MP3 decoding via AudioToolbox on macOS
|
# Needed for MP3 decoding via AudioToolbox on macOS
|
||||||
|
|||||||
@ -14,7 +14,7 @@ SmoothScroll=1
|
|||||||
UpRotateClockwise=0
|
UpRotateClockwise=0
|
||||||
|
|
||||||
[Player]
|
[Player]
|
||||||
Name=P2
|
Name=GREGOR
|
||||||
|
|
||||||
[Debug]
|
[Debug]
|
||||||
Enabled=1
|
Enabled=1
|
||||||
|
|||||||
@ -49,6 +49,9 @@
|
|||||||
#include "graphics/ui/Font.h"
|
#include "graphics/ui/Font.h"
|
||||||
#include "graphics/ui/HelpOverlay.h"
|
#include "graphics/ui/HelpOverlay.h"
|
||||||
|
|
||||||
|
#include "network/CoopNetButtons.h"
|
||||||
|
#include "network/NetSession.h"
|
||||||
|
|
||||||
#include "persistence/Scores.h"
|
#include "persistence/Scores.h"
|
||||||
|
|
||||||
#include "states/LevelSelectorState.h"
|
#include "states/LevelSelectorState.h"
|
||||||
@ -259,6 +262,12 @@ struct TetrisApp::Impl {
|
|||||||
double moveTimerMs = 0.0;
|
double moveTimerMs = 0.0;
|
||||||
double p1MoveTimerMs = 0.0;
|
double p1MoveTimerMs = 0.0;
|
||||||
double p2MoveTimerMs = 0.0;
|
double p2MoveTimerMs = 0.0;
|
||||||
|
|
||||||
|
// Network coop fixed-tick state (used only when ctx.coopNetEnabled is true)
|
||||||
|
double coopNetAccMs = 0.0;
|
||||||
|
uint32_t coopNetCachedTick = 0xFFFFFFFFu;
|
||||||
|
uint8_t coopNetCachedButtons = 0;
|
||||||
|
uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu;
|
||||||
double DAS = 170.0;
|
double DAS = 170.0;
|
||||||
double ARR = 40.0;
|
double ARR = 40.0;
|
||||||
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
||||||
@ -1149,6 +1158,10 @@ void TetrisApp::Impl::runLoop()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State transitions can be triggered from render/update (e.g. menu network handshake).
|
||||||
|
// Keep our cached `state` in sync every frame, not only when events occur.
|
||||||
|
state = stateMgr->getState();
|
||||||
|
|
||||||
Uint64 now = SDL_GetPerformanceCounter();
|
Uint64 now = SDL_GetPerformanceCounter();
|
||||||
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
|
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
|
||||||
lastMs = now;
|
lastMs = now;
|
||||||
@ -1309,6 +1322,10 @@ void TetrisApp::Impl::runLoop()
|
|||||||
|
|
||||||
if (game->isPaused()) {
|
if (game->isPaused()) {
|
||||||
// While paused, suppress all continuous input changes so pieces don't drift.
|
// While paused, suppress all continuous input changes so pieces don't drift.
|
||||||
|
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->poll(0);
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||||
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||||
p1MoveTimerMs = 0.0;
|
p1MoveTimerMs = 0.0;
|
||||||
@ -1318,6 +1335,17 @@ void TetrisApp::Impl::runLoop()
|
|||||||
p2LeftHeld = false;
|
p2LeftHeld = false;
|
||||||
p2RightHeld = false;
|
p2RightHeld = false;
|
||||||
} else {
|
} else {
|
||||||
|
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
|
||||||
|
|
||||||
|
// If we just entered network co-op, reset per-session fixed-tick bookkeeping.
|
||||||
|
if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) {
|
||||||
|
coopNetAccMs = 0.0;
|
||||||
|
coopNetCachedTick = 0xFFFFFFFFu;
|
||||||
|
coopNetCachedButtons = 0;
|
||||||
|
coopNetLastHashSentTick = 0xFFFFFFFFu;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Define canonical key mappings for left and right players
|
// Define canonical key mappings for left and right players
|
||||||
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
|
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
|
||||||
const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
|
const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
|
||||||
@ -1327,7 +1355,194 @@ void TetrisApp::Impl::runLoop()
|
|||||||
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
|
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
|
||||||
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
|
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
|
||||||
|
|
||||||
if (!coopVsAI) {
|
if (coopNetActive) {
|
||||||
|
// Network co-op: fixed tick lockstep.
|
||||||
|
// Use a fixed dt so both peers simulate identically.
|
||||||
|
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
|
||||||
|
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
|
||||||
|
|
||||||
|
ctx.coopNetSession->poll(0);
|
||||||
|
|
||||||
|
// If the connection drops during gameplay, abort back to menu.
|
||||||
|
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
|
||||||
|
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
|
||||||
|
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
|
||||||
|
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
|
||||||
|
: std::string("NET DISCONNECTED");
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
|
||||||
|
|
||||||
|
ctx.coopNetUiStatusText = reason;
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 6000.0;
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
ctx.coopNetDesyncDetected = false;
|
||||||
|
ctx.coopNetTick = 0;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't remain paused due to a previous net stall/desync.
|
||||||
|
if (game) {
|
||||||
|
game->setPaused(false);
|
||||||
|
}
|
||||||
|
state = AppState::Menu;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
|
||||||
|
|
||||||
|
auto buildLocalButtons = [&]() -> uint8_t {
|
||||||
|
uint8_t b = 0;
|
||||||
|
if (ctx.coopNetLocalIsLeft) {
|
||||||
|
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
|
||||||
|
if (ks[leftRightKey]) b |= coopnet::MoveRight;
|
||||||
|
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
|
||||||
|
} else {
|
||||||
|
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
|
||||||
|
if (ks[rightRightKey]) b |= coopnet::MoveRight;
|
||||||
|
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
|
||||||
|
}
|
||||||
|
b |= ctx.coopNetPendingButtons;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
|
||||||
|
uint8_t buttons,
|
||||||
|
bool& leftHeldPrev,
|
||||||
|
bool& rightHeldPrev,
|
||||||
|
double& timer) {
|
||||||
|
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
|
||||||
|
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
|
||||||
|
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
|
||||||
|
|
||||||
|
coopGame->setSoftDropping(side, downHeldNow);
|
||||||
|
|
||||||
|
int moveDir = 0;
|
||||||
|
if (leftHeldNow && !rightHeldNow) moveDir = -1;
|
||||||
|
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= FIXED_DT_MS;
|
||||||
|
if (timer <= 0.0) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coopnet::has(buttons, coopnet::RotCW)) {
|
||||||
|
coopGame->rotate(side, +1);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::RotCCW)) {
|
||||||
|
coopGame->rotate(side, -1);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::HardDrop)) {
|
||||||
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
|
coopGame->hardDrop(side);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::Hold)) {
|
||||||
|
coopGame->holdCurrent(side);
|
||||||
|
}
|
||||||
|
|
||||||
|
leftHeldPrev = leftHeldNow;
|
||||||
|
rightHeldPrev = rightHeldNow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
|
||||||
|
|
||||||
|
int safetySteps = 0;
|
||||||
|
bool advancedTick = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
|
||||||
|
const uint32_t tick = ctx.coopNetTick;
|
||||||
|
|
||||||
|
if (coopNetCachedTick != tick) {
|
||||||
|
coopNetCachedTick = tick;
|
||||||
|
coopNetCachedButtons = buildLocalButtons();
|
||||||
|
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
|
||||||
|
roleStr,
|
||||||
|
tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
|
||||||
|
if (!remoteButtonsOpt.has_value()) {
|
||||||
|
if (!ctx.coopNetStalled) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] STALL begin waitingForTick=%u",
|
||||||
|
roleStr,
|
||||||
|
tick);
|
||||||
|
}
|
||||||
|
ctx.coopNetStalled = true;
|
||||||
|
break; // lockstep stall
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t remoteButtons = remoteButtonsOpt.value();
|
||||||
|
const bool localIsLeft = ctx.coopNetLocalIsLeft;
|
||||||
|
|
||||||
|
if (localIsLeft) {
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
|
||||||
|
} else {
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
coopGame->tickGravity(FIXED_DT_MS);
|
||||||
|
coopGame->updateVisualEffects(FIXED_DT_MS);
|
||||||
|
|
||||||
|
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
|
||||||
|
coopNetLastHashSentTick = tick;
|
||||||
|
const uint64_t hash = coopGame->computeStateHash();
|
||||||
|
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
|
||||||
|
roleStr,
|
||||||
|
tick,
|
||||||
|
(unsigned long long)hash);
|
||||||
|
}
|
||||||
|
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
|
||||||
|
if (rh.has_value() && rh.value() != hash) {
|
||||||
|
ctx.coopNetDesyncDetected = true;
|
||||||
|
ctx.coopNetUiStatusText = "NET DESYNC";
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 8000.0;
|
||||||
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
|
||||||
|
roleStr,
|
||||||
|
tick,
|
||||||
|
(unsigned long long)hash,
|
||||||
|
(unsigned long long)rh.value());
|
||||||
|
game->setPaused(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.coopNetTick++;
|
||||||
|
advancedTick = true;
|
||||||
|
coopNetAccMs -= FIXED_DT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advancedTick) {
|
||||||
|
if (ctx.coopNetStalled) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] STALL end atTick=%u",
|
||||||
|
roleStr,
|
||||||
|
ctx.coopNetTick);
|
||||||
|
}
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
} else if (!coopVsAI) {
|
||||||
// Standard two-player: left uses WASD, right uses arrow keys
|
// Standard two-player: left uses WASD, right uses arrow keys
|
||||||
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
|
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
|
||||||
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
|
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
|
||||||
@ -1357,8 +1572,10 @@ void TetrisApp::Impl::runLoop()
|
|||||||
p2RightHeld = ks[rightRightKey];
|
p2RightHeld = ks[rightRightKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
coopGame->tickGravity(frameMs);
|
if (!coopNetActive) {
|
||||||
coopGame->updateVisualEffects(frameMs);
|
coopGame->tickGravity(frameMs);
|
||||||
|
coopGame->updateVisualEffects(frameMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coopGame->isGameOver()) {
|
if (coopGame->isGameOver()) {
|
||||||
@ -1387,6 +1604,12 @@ void TetrisApp::Impl::runLoop()
|
|||||||
}
|
}
|
||||||
state = AppState::GameOver;
|
state = AppState::GameOver;
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
|
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
||||||
@ -41,7 +42,23 @@ CoopGame::CoopGame(int startLevel_) {
|
|||||||
reset(startLevel_);
|
reset(startLevel_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CoopGame::reset(int startLevel_) {
|
namespace {
|
||||||
|
uint64_t fnv1a64(uint64_t h, const void* data, size_t size) {
|
||||||
|
const uint8_t* p = static_cast<const uint8_t*>(data);
|
||||||
|
for (size_t i = 0; i < size; ++i) {
|
||||||
|
h ^= static_cast<uint64_t>(p[i]);
|
||||||
|
h *= 1099511628211ull;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
uint64_t hashPod(uint64_t h, const T& v) {
|
||||||
|
return fnv1a64(h, &v, sizeof(T));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt) {
|
||||||
std::fill(board.begin(), board.end(), Cell{});
|
std::fill(board.begin(), board.end(), Cell{});
|
||||||
rowStates.fill(RowHalfState{});
|
rowStates.fill(RowHalfState{});
|
||||||
completedLines.clear();
|
completedLines.clear();
|
||||||
@ -60,7 +77,7 @@ void CoopGame::reset(int startLevel_) {
|
|||||||
left = PlayerState{};
|
left = PlayerState{};
|
||||||
right = PlayerState{ PlayerSide::Right };
|
right = PlayerState{ PlayerSide::Right };
|
||||||
|
|
||||||
auto initPlayer = [&](PlayerState& ps) {
|
auto initPlayer = [&](PlayerState& ps, uint32_t seed) {
|
||||||
ps.canHold = true;
|
ps.canHold = true;
|
||||||
ps.hold.type = PIECE_COUNT;
|
ps.hold.type = PIECE_COUNT;
|
||||||
ps.softDropping = false;
|
ps.softDropping = false;
|
||||||
@ -77,16 +94,34 @@ void CoopGame::reset(int startLevel_) {
|
|||||||
ps.comboCount = 0;
|
ps.comboCount = 0;
|
||||||
ps.bag.clear();
|
ps.bag.clear();
|
||||||
ps.next.type = PIECE_COUNT;
|
ps.next.type = PIECE_COUNT;
|
||||||
|
ps.rng.seed(seed);
|
||||||
refillBag(ps);
|
refillBag(ps);
|
||||||
};
|
};
|
||||||
initPlayer(left);
|
|
||||||
initPlayer(right);
|
if (seedOpt.has_value()) {
|
||||||
|
const uint32_t seed = seedOpt.value();
|
||||||
|
initPlayer(left, seed);
|
||||||
|
initPlayer(right, seed ^ 0x9E3779B9u);
|
||||||
|
} else {
|
||||||
|
// Preserve existing behavior: random seed when not in deterministic mode.
|
||||||
|
std::random_device rd;
|
||||||
|
initPlayer(left, static_cast<uint32_t>(rd()));
|
||||||
|
initPlayer(right, static_cast<uint32_t>(rd()));
|
||||||
|
}
|
||||||
|
|
||||||
spawn(left);
|
spawn(left);
|
||||||
spawn(right);
|
spawn(right);
|
||||||
updateRowStates();
|
updateRowStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CoopGame::reset(int startLevel_) {
|
||||||
|
resetInternal(startLevel_, std::nullopt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) {
|
||||||
|
resetInternal(startLevel_, seed);
|
||||||
|
}
|
||||||
|
|
||||||
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
||||||
PlayerState& ps = player(side);
|
PlayerState& ps = player(side);
|
||||||
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
||||||
@ -103,6 +138,74 @@ void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
|||||||
ps.softDropping = on;
|
ps.softDropping = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint64_t CoopGame::computeStateHash() const {
|
||||||
|
uint64_t h = 1469598103934665603ull;
|
||||||
|
|
||||||
|
// Board
|
||||||
|
for (const auto& c : board) {
|
||||||
|
const uint8_t occ = c.occupied ? 1u : 0u;
|
||||||
|
const uint8_t owner = (c.owner == PlayerSide::Left) ? 0u : 1u;
|
||||||
|
const uint8_t val = static_cast<uint8_t>(std::clamp(c.value, 0, 255));
|
||||||
|
h = hashPod(h, occ);
|
||||||
|
h = hashPod(h, owner);
|
||||||
|
h = hashPod(h, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto hashPiece = [&](const Piece& p) {
|
||||||
|
const uint8_t type = static_cast<uint8_t>(p.type);
|
||||||
|
const int32_t rot = p.rot;
|
||||||
|
const int32_t x = p.x;
|
||||||
|
const int32_t y = p.y;
|
||||||
|
h = hashPod(h, type);
|
||||||
|
h = hashPod(h, rot);
|
||||||
|
h = hashPod(h, x);
|
||||||
|
h = hashPod(h, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
auto hashPlayer = [&](const PlayerState& ps) {
|
||||||
|
const uint8_t side = (ps.side == PlayerSide::Left) ? 0u : 1u;
|
||||||
|
h = hashPod(h, side);
|
||||||
|
hashPiece(ps.cur);
|
||||||
|
hashPiece(ps.next);
|
||||||
|
hashPiece(ps.hold);
|
||||||
|
const uint8_t canHoldB = ps.canHold ? 1u : 0u;
|
||||||
|
const uint8_t toppedOutB = ps.toppedOut ? 1u : 0u;
|
||||||
|
h = hashPod(h, canHoldB);
|
||||||
|
h = hashPod(h, toppedOutB);
|
||||||
|
h = hashPod(h, ps.score);
|
||||||
|
h = hashPod(h, ps.lines);
|
||||||
|
h = hashPod(h, ps.level);
|
||||||
|
h = hashPod(h, ps.tetrisesMade);
|
||||||
|
h = hashPod(h, ps.currentCombo);
|
||||||
|
h = hashPod(h, ps.maxCombo);
|
||||||
|
h = hashPod(h, ps.comboCount);
|
||||||
|
h = hashPod(h, ps.pieceSeq);
|
||||||
|
|
||||||
|
const uint32_t bagSize = static_cast<uint32_t>(ps.bag.size());
|
||||||
|
h = hashPod(h, bagSize);
|
||||||
|
for (auto t : ps.bag) {
|
||||||
|
const uint8_t tt = static_cast<uint8_t>(t);
|
||||||
|
h = hashPod(h, tt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hashPlayer(left);
|
||||||
|
hashPlayer(right);
|
||||||
|
|
||||||
|
// Session-wide counters/stats
|
||||||
|
h = hashPod(h, _score);
|
||||||
|
h = hashPod(h, _lines);
|
||||||
|
h = hashPod(h, _level);
|
||||||
|
h = hashPod(h, _tetrisesMade);
|
||||||
|
h = hashPod(h, _currentCombo);
|
||||||
|
h = hashPod(h, _maxCombo);
|
||||||
|
h = hashPod(h, _comboCount);
|
||||||
|
h = hashPod(h, startLevel);
|
||||||
|
h = hashPod(h, pieceSequence);
|
||||||
|
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
void CoopGame::move(PlayerSide side, int dx) {
|
void CoopGame::move(PlayerSide side, int dx) {
|
||||||
PlayerState& ps = player(side);
|
PlayerState& ps = player(side);
|
||||||
if (gameOver || ps.toppedOut) return;
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
|||||||
@ -62,9 +62,13 @@ public:
|
|||||||
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
|
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
|
||||||
|
|
||||||
void reset(int startLevel = 0);
|
void reset(int startLevel = 0);
|
||||||
|
void resetDeterministic(int startLevel, uint32_t seed);
|
||||||
void tickGravity(double frameMs);
|
void tickGravity(double frameMs);
|
||||||
void updateVisualEffects(double frameMs);
|
void updateVisualEffects(double frameMs);
|
||||||
|
|
||||||
|
// Determinism / desync detection
|
||||||
|
uint64_t computeStateHash() const;
|
||||||
|
|
||||||
// Per-player inputs -----------------------------------------------------
|
// Per-player inputs -----------------------------------------------------
|
||||||
void setSoftDropping(PlayerSide side, bool on);
|
void setSoftDropping(PlayerSide side, bool on);
|
||||||
void move(PlayerSide side, int dx);
|
void move(PlayerSide side, int dx);
|
||||||
@ -111,6 +115,8 @@ public:
|
|||||||
private:
|
private:
|
||||||
static constexpr double LOCK_DELAY_MS = 500.0;
|
static constexpr double LOCK_DELAY_MS = 500.0;
|
||||||
|
|
||||||
|
void resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt);
|
||||||
|
|
||||||
std::array<Cell, COLS * ROWS> board{};
|
std::array<Cell, COLS * ROWS> board{};
|
||||||
std::array<RowHalfState, ROWS> rowStates{};
|
std::array<RowHalfState, ROWS> rowStates{};
|
||||||
PlayerState left{};
|
PlayerState left{};
|
||||||
|
|||||||
21
src/network/CoopNetButtons.h
Normal file
21
src/network/CoopNetButtons.h
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace coopnet {
|
||||||
|
// 8-bit input mask carried in NetSession::InputFrame.
|
||||||
|
// Keep in sync across capture/apply on both peers.
|
||||||
|
enum Buttons : uint8_t {
|
||||||
|
MoveLeft = 1u << 0,
|
||||||
|
MoveRight = 1u << 1,
|
||||||
|
SoftDrop = 1u << 2,
|
||||||
|
RotCW = 1u << 3,
|
||||||
|
RotCCW = 1u << 4,
|
||||||
|
HardDrop = 1u << 5,
|
||||||
|
Hold = 1u << 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool has(uint8_t mask, Buttons b) {
|
||||||
|
return (mask & static_cast<uint8_t>(b)) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/network/NetSession.cpp
Normal file
324
src/network/NetSession.cpp
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
#include "NetSession.h"
|
||||||
|
|
||||||
|
#include <enet/enet.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint8_t kChannelReliable = 0;
|
||||||
|
|
||||||
|
static bool netLogVerboseEnabled() {
|
||||||
|
// Set environment variable / hint: SPACETRIS_NET_LOG=1
|
||||||
|
const char* v = SDL_GetHint("SPACETRIS_NET_LOG");
|
||||||
|
return v && v[0] == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
static void append(std::vector<uint8_t>& out, const T& value) {
|
||||||
|
const uint8_t* p = reinterpret_cast<const uint8_t*>(&value);
|
||||||
|
out.insert(out.end(), p, p + sizeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
static bool read(const uint8_t* data, size_t size, size_t& off, T& out) {
|
||||||
|
if (off + sizeof(T) > size) return false;
|
||||||
|
std::memcpy(&out, data + off, sizeof(T));
|
||||||
|
off += sizeof(T);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NetSession::NetSession() = default;
|
||||||
|
|
||||||
|
NetSession::~NetSession() {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::ensureEnetInitialized() {
|
||||||
|
static bool s_inited = false;
|
||||||
|
if (s_inited) return true;
|
||||||
|
if (enet_initialize() != 0) {
|
||||||
|
setError("enet_initialize failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s_inited = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::setError(const std::string& msg) {
|
||||||
|
m_lastError = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::host(const std::string& bindHost, uint16_t port) {
|
||||||
|
shutdown();
|
||||||
|
if (!ensureEnetInitialized()) return false;
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] host(bind='%s', port=%u)", bindHost.c_str(), (unsigned)port);
|
||||||
|
|
||||||
|
ENetAddress address{};
|
||||||
|
address.host = ENET_HOST_ANY;
|
||||||
|
address.port = port;
|
||||||
|
|
||||||
|
if (!bindHost.empty() && bindHost != "0.0.0.0") {
|
||||||
|
if (enet_address_set_host(&address, bindHost.c_str()) != 0) {
|
||||||
|
setError("enet_address_set_host (bind) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 peer, 2 channels (reserve extra)
|
||||||
|
m_host = enet_host_create(&address, 1, 2, 0, 0);
|
||||||
|
if (!m_host) {
|
||||||
|
setError("enet_host_create (host) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::Host;
|
||||||
|
m_state = ConnState::Connecting;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::join(const std::string& hostNameOrIp, uint16_t port) {
|
||||||
|
shutdown();
|
||||||
|
if (!ensureEnetInitialized()) return false;
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] join(remote='%s', port=%u)", hostNameOrIp.c_str(), (unsigned)port);
|
||||||
|
|
||||||
|
m_host = enet_host_create(nullptr, 1, 2, 0, 0);
|
||||||
|
if (!m_host) {
|
||||||
|
setError("enet_host_create (client) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ENetAddress address{};
|
||||||
|
if (enet_address_set_host(&address, hostNameOrIp.c_str()) != 0) {
|
||||||
|
setError("enet_address_set_host failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
address.port = port;
|
||||||
|
|
||||||
|
m_peer = enet_host_connect(m_host, &address, 2, 0);
|
||||||
|
if (!m_peer) {
|
||||||
|
setError("enet_host_connect failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::Client;
|
||||||
|
m_state = ConnState::Connecting;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::shutdown() {
|
||||||
|
if (m_host || m_peer) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] shutdown(mode=%d state=%d)", (int)m_mode, (int)m_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remoteInputs.clear();
|
||||||
|
m_remoteHashes.clear();
|
||||||
|
m_receivedHandshake.reset();
|
||||||
|
|
||||||
|
m_inputsSent = 0;
|
||||||
|
m_inputsReceived = 0;
|
||||||
|
m_hashesSent = 0;
|
||||||
|
m_hashesReceived = 0;
|
||||||
|
m_handshakesSent = 0;
|
||||||
|
m_handshakesReceived = 0;
|
||||||
|
m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||||
|
m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||||
|
m_lastStatsLogMs = 0;
|
||||||
|
|
||||||
|
if (m_peer) {
|
||||||
|
enet_peer_disconnect(m_peer, 0);
|
||||||
|
m_peer = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_host) {
|
||||||
|
enet_host_destroy(m_host);
|
||||||
|
m_host = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::None;
|
||||||
|
m_state = ConnState::Disconnected;
|
||||||
|
m_lastError.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::poll(uint32_t timeoutMs) {
|
||||||
|
if (!m_host) return;
|
||||||
|
|
||||||
|
ENetEvent event{};
|
||||||
|
while (enet_host_service(m_host, &event, static_cast<enet_uint32>(timeoutMs)) > 0) {
|
||||||
|
switch (event.type) {
|
||||||
|
case ENET_EVENT_TYPE_CONNECT:
|
||||||
|
m_peer = event.peer;
|
||||||
|
m_state = ConnState::Connected;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] connected (mode=%d)", (int)m_mode);
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_RECEIVE:
|
||||||
|
if (event.packet) {
|
||||||
|
handlePacket(event.packet->data, event.packet->dataLength);
|
||||||
|
enet_packet_destroy(event.packet);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_DISCONNECT:
|
||||||
|
m_peer = nullptr;
|
||||||
|
m_state = ConnState::Disconnected;
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET] disconnected");
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_NONE:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After first event, do non-blocking passes.
|
||||||
|
timeoutMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limited stats log (opt-in)
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
const uint32_t nowMs = SDL_GetTicks();
|
||||||
|
if (m_lastStatsLogMs == 0) m_lastStatsLogMs = nowMs;
|
||||||
|
if (nowMs - m_lastStatsLogMs >= 1000u) {
|
||||||
|
m_lastStatsLogMs = nowMs;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET] stats: sent(in=%u hash=%u hs=%u) recv(in=%u hash=%u hs=%u) lastRecv(inTick=%u hashTick=%u) state=%d",
|
||||||
|
m_inputsSent,
|
||||||
|
m_hashesSent,
|
||||||
|
m_handshakesSent,
|
||||||
|
m_inputsReceived,
|
||||||
|
m_hashesReceived,
|
||||||
|
m_handshakesReceived,
|
||||||
|
m_lastRecvInputTick,
|
||||||
|
m_lastRecvHashTick,
|
||||||
|
(int)m_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendBytesReliable(const void* data, size_t size) {
|
||||||
|
if (!m_peer) return false;
|
||||||
|
ENetPacket* packet = enet_packet_create(data, size, ENET_PACKET_FLAG_RELIABLE);
|
||||||
|
if (!packet) return false;
|
||||||
|
if (enet_peer_send(m_peer, kChannelReliable, packet) != 0) {
|
||||||
|
enet_packet_destroy(packet);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Let the caller decide flush cadence; but for tiny control packets, flushing is cheap.
|
||||||
|
enet_host_flush(m_host);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendHandshake(const Handshake& hs) {
|
||||||
|
if (m_mode != Mode::Host) return false;
|
||||||
|
m_handshakesSent++;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Handshake));
|
||||||
|
append(buf, hs.rngSeed);
|
||||||
|
append(buf, hs.startTick);
|
||||||
|
append(buf, hs.startLevel);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<NetSession::Handshake> NetSession::takeReceivedHandshake() {
|
||||||
|
auto out = m_receivedHandshake;
|
||||||
|
m_receivedHandshake.reset();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendLocalInput(uint32_t tick, uint8_t buttons) {
|
||||||
|
m_inputsSent++;
|
||||||
|
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||||
|
}
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Input));
|
||||||
|
append(buf, tick);
|
||||||
|
append(buf, buttons);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint8_t> NetSession::getRemoteButtons(uint32_t tick) const {
|
||||||
|
auto it = m_remoteInputs.find(tick);
|
||||||
|
if (it == m_remoteInputs.end()) return std::nullopt;
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendStateHash(uint32_t tick, uint64_t hash) {
|
||||||
|
m_hashesSent++;
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||||
|
}
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Hash));
|
||||||
|
append(buf, tick);
|
||||||
|
append(buf, hash);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint64_t> NetSession::takeRemoteHash(uint32_t tick) {
|
||||||
|
auto it = m_remoteHashes.find(tick);
|
||||||
|
if (it == m_remoteHashes.end()) return std::nullopt;
|
||||||
|
uint64_t v = it->second;
|
||||||
|
m_remoteHashes.erase(it);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::handlePacket(const uint8_t* data, size_t size) {
|
||||||
|
if (!data || size < 1) return;
|
||||||
|
size_t off = 0;
|
||||||
|
uint8_t typeByte = 0;
|
||||||
|
if (!read(data, size, off, typeByte)) return;
|
||||||
|
|
||||||
|
MsgType t = static_cast<MsgType>(typeByte);
|
||||||
|
switch (t) {
|
||||||
|
case MsgType::Handshake: {
|
||||||
|
Handshake hs{};
|
||||||
|
if (!read(data, size, off, hs.rngSeed)) return;
|
||||||
|
if (!read(data, size, off, hs.startTick)) return;
|
||||||
|
if (!read(data, size, off, hs.startLevel)) return;
|
||||||
|
m_receivedHandshake = hs;
|
||||||
|
m_handshakesReceived++;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MsgType::Input: {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint8_t buttons = 0;
|
||||||
|
if (!read(data, size, off, tick)) return;
|
||||||
|
if (!read(data, size, off, buttons)) return;
|
||||||
|
m_remoteInputs[tick] = buttons;
|
||||||
|
m_inputsReceived++;
|
||||||
|
m_lastRecvInputTick = tick;
|
||||||
|
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MsgType::Hash: {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint64_t hash = 0;
|
||||||
|
if (!read(data, size, off, tick)) return;
|
||||||
|
if (!read(data, size, off, hash)) return;
|
||||||
|
m_remoteHashes[tick] = hash;
|
||||||
|
m_hashesReceived++;
|
||||||
|
m_lastRecvHashTick = tick;
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/network/NetSession.h
Normal file
118
src/network/NetSession.h
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct _ENetHost;
|
||||||
|
struct _ENetPeer;
|
||||||
|
|
||||||
|
// Lockstep networking session for COOPERATE (network) mode.
|
||||||
|
//
|
||||||
|
// Design goals:
|
||||||
|
// - Non-blocking polling (caller drives poll from the main loop)
|
||||||
|
// - Reliable, ordered delivery for inputs and control messages
|
||||||
|
// - Host provides seed + start tick (handshake)
|
||||||
|
// - Only inputs/state hashes are exchanged (no board sync)
|
||||||
|
class NetSession {
|
||||||
|
public:
|
||||||
|
enum class Mode {
|
||||||
|
None,
|
||||||
|
Host,
|
||||||
|
Client,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ConnState {
|
||||||
|
Disconnected,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Handshake {
|
||||||
|
uint32_t rngSeed = 0;
|
||||||
|
uint32_t startTick = 0;
|
||||||
|
uint8_t startLevel = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct InputFrame {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint8_t buttons = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
NetSession();
|
||||||
|
~NetSession();
|
||||||
|
|
||||||
|
NetSession(const NetSession&) = delete;
|
||||||
|
NetSession& operator=(const NetSession&) = delete;
|
||||||
|
|
||||||
|
// If bindHost is empty or "0.0.0.0", binds to ENET_HOST_ANY.
|
||||||
|
bool host(const std::string& bindHost, uint16_t port);
|
||||||
|
bool join(const std::string& hostNameOrIp, uint16_t port);
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
void poll(uint32_t timeoutMs = 0);
|
||||||
|
|
||||||
|
Mode mode() const { return m_mode; }
|
||||||
|
ConnState state() const { return m_state; }
|
||||||
|
bool isConnected() const { return m_state == ConnState::Connected; }
|
||||||
|
|
||||||
|
// Host-only: send handshake once the peer connects.
|
||||||
|
bool sendHandshake(const Handshake& hs);
|
||||||
|
|
||||||
|
// Client-only: becomes available once received from host.
|
||||||
|
std::optional<Handshake> takeReceivedHandshake();
|
||||||
|
|
||||||
|
// Input exchange --------------------------------------------------------
|
||||||
|
// Send local input for a given simulation tick.
|
||||||
|
bool sendLocalInput(uint32_t tick, uint8_t buttons);
|
||||||
|
|
||||||
|
// Returns the last received remote input for a tick (if any).
|
||||||
|
std::optional<uint8_t> getRemoteButtons(uint32_t tick) const;
|
||||||
|
|
||||||
|
// Hash exchange (for desync detection) ---------------------------------
|
||||||
|
bool sendStateHash(uint32_t tick, uint64_t hash);
|
||||||
|
std::optional<uint64_t> takeRemoteHash(uint32_t tick);
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
std::string lastError() const { return m_lastError; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class MsgType : uint8_t {
|
||||||
|
Handshake = 1,
|
||||||
|
Input = 2,
|
||||||
|
Hash = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ensureEnetInitialized();
|
||||||
|
void setError(const std::string& msg);
|
||||||
|
|
||||||
|
bool sendBytesReliable(const void* data, size_t size);
|
||||||
|
void handlePacket(const uint8_t* data, size_t size);
|
||||||
|
|
||||||
|
Mode m_mode = Mode::None;
|
||||||
|
ConnState m_state = ConnState::Disconnected;
|
||||||
|
|
||||||
|
_ENetHost* m_host = nullptr;
|
||||||
|
_ENetPeer* m_peer = nullptr;
|
||||||
|
|
||||||
|
std::string m_lastError;
|
||||||
|
|
||||||
|
std::optional<Handshake> m_receivedHandshake;
|
||||||
|
|
||||||
|
std::unordered_map<uint32_t, uint8_t> m_remoteInputs;
|
||||||
|
std::unordered_map<uint32_t, uint64_t> m_remoteHashes;
|
||||||
|
|
||||||
|
// Debug logging (rate-limited)
|
||||||
|
uint32_t m_inputsSent = 0;
|
||||||
|
uint32_t m_inputsReceived = 0;
|
||||||
|
uint32_t m_hashesSent = 0;
|
||||||
|
uint32_t m_hashesReceived = 0;
|
||||||
|
uint32_t m_handshakesSent = 0;
|
||||||
|
uint32_t m_handshakesReceived = 0;
|
||||||
|
uint32_t m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||||
|
uint32_t m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||||
|
uint32_t m_lastStatsLogMs = 0;
|
||||||
|
};
|
||||||
@ -1,6 +1,7 @@
|
|||||||
#include "MenuState.h"
|
#include "MenuState.h"
|
||||||
#include "persistence/Scores.h"
|
#include "persistence/Scores.h"
|
||||||
#include "../network/supabase_client.h"
|
#include "../network/supabase_client.h"
|
||||||
|
#include "../network/NetSession.h"
|
||||||
#include "graphics/Font.h"
|
#include "graphics/Font.h"
|
||||||
#include "../graphics/ui/HelpOverlay.h"
|
#include "../graphics/ui/HelpOverlay.h"
|
||||||
#include "../core/GlobalState.h"
|
#include "../core/GlobalState.h"
|
||||||
@ -16,6 +17,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <random>
|
||||||
|
|
||||||
// Use dynamic logical dimensions from GlobalState instead of hardcoded values
|
// Use dynamic logical dimensions from GlobalState instead of hardcoded values
|
||||||
// This allows the UI to adapt when the window is resized or goes fullscreen
|
// This allows the UI to adapt when the window is resized or goes fullscreen
|
||||||
@ -141,6 +143,17 @@ void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
|
|||||||
coopSetupAnimating = true;
|
coopSetupAnimating = true;
|
||||||
coopSetupDirection = 1;
|
coopSetupDirection = 1;
|
||||||
coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0;
|
coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0;
|
||||||
|
coopSetupStep = CoopSetupStep::ChoosePartner;
|
||||||
|
coopNetworkRoleSelected = 0;
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||||||
|
SDL_StopTextInput(focusWin);
|
||||||
|
}
|
||||||
|
if (coopNetworkSession) {
|
||||||
|
coopNetworkSession->shutdown();
|
||||||
|
coopNetworkSession.reset();
|
||||||
|
}
|
||||||
coopSetupRectsValid = false;
|
coopSetupRectsValid = false;
|
||||||
selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate);
|
selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate);
|
||||||
// Ensure the transition value is non-zero so render code can show
|
// Ensure the transition value is non-zero so render code can show
|
||||||
@ -152,6 +165,19 @@ void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
|
|||||||
coopSetupAnimating = true;
|
coopSetupAnimating = true;
|
||||||
coopSetupDirection = -1;
|
coopSetupDirection = -1;
|
||||||
coopSetupRectsValid = false;
|
coopSetupRectsValid = false;
|
||||||
|
|
||||||
|
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||||||
|
SDL_StopTextInput(focusWin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending network session if the coop setup is being closed.
|
||||||
|
if (coopNetworkSession) {
|
||||||
|
coopNetworkSession->shutdown();
|
||||||
|
coopNetworkSession.reset();
|
||||||
|
}
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
coopSetupStep = CoopSetupStep::ChoosePartner;
|
||||||
// Resume menu music only when requested (ESC should pass resumeMusic=false)
|
// Resume menu music only when requested (ESC should pass resumeMusic=false)
|
||||||
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
|
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
|
||||||
Audio::instance().playMenuMusic();
|
Audio::instance().playMenuMusic();
|
||||||
@ -280,58 +306,196 @@ void MenuState::onExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuState::handleEvent(const SDL_Event& e) {
|
void MenuState::handleEvent(const SDL_Event& e) {
|
||||||
|
// Text input for network IP entry (only while coop setup panel is active).
|
||||||
|
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_TEXT_INPUT) {
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
|
||||||
|
if (target.size() < 64) {
|
||||||
|
target += e.text.text;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Coop setup panel navigation (modal within the menu)
|
// Coop setup panel navigation (modal within the menu)
|
||||||
// Handle this FIRST and consume key events so the main menu navigation doesn't interfere.
|
// Handle this FIRST and consume key events so the main menu navigation doesn't interfere.
|
||||||
// Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat.
|
// Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat.
|
||||||
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) {
|
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) {
|
||||||
|
// Coop setup panel navigation (modal within the menu)
|
||||||
switch (e.key.scancode) {
|
switch (e.key.scancode) {
|
||||||
|
case SDL_SCANCODE_UP:
|
||||||
|
case SDL_SCANCODE_DOWN:
|
||||||
|
// Do NOT allow up/down to change anything while this panel is active
|
||||||
|
return;
|
||||||
|
case SDL_SCANCODE_ESCAPE:
|
||||||
|
// When in a nested network step, go back one step; otherwise close the panel.
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||||||
|
coopSetupStep = CoopSetupStep::ChoosePartner;
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||||||
|
SDL_StopTextInput(focusWin);
|
||||||
|
}
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkWaiting) {
|
||||||
|
if (coopNetworkSession) {
|
||||||
|
coopNetworkSession->shutdown();
|
||||||
|
coopNetworkSession.reset();
|
||||||
|
}
|
||||||
|
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||||||
|
SDL_StopTextInput(focusWin);
|
||||||
|
}
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCoopSetupPanel(false, false);
|
||||||
|
return;
|
||||||
case SDL_SCANCODE_LEFT:
|
case SDL_SCANCODE_LEFT:
|
||||||
case SDL_SCANCODE_A:
|
case SDL_SCANCODE_A:
|
||||||
coopSetupSelected = 0;
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
buttonFlash = 1.0;
|
// 3-way selection: LOCAL / AI / NETWORK
|
||||||
|
coopSetupSelected = (coopSetupSelected + 3 - 1) % 3;
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||||||
|
coopNetworkRoleSelected = (coopNetworkRoleSelected + 2 - 1) % 2;
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case SDL_SCANCODE_RIGHT:
|
case SDL_SCANCODE_RIGHT:
|
||||||
case SDL_SCANCODE_D:
|
case SDL_SCANCODE_D:
|
||||||
coopSetupSelected = 1;
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
buttonFlash = 1.0;
|
coopSetupSelected = (coopSetupSelected + 1) % 3;
|
||||||
return;
|
buttonFlash = 1.0;
|
||||||
// Do NOT allow up/down to change anything
|
return;
|
||||||
case SDL_SCANCODE_UP:
|
}
|
||||||
case SDL_SCANCODE_DOWN:
|
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||||||
return;
|
coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2;
|
||||||
case SDL_SCANCODE_ESCAPE:
|
buttonFlash = 1.0;
|
||||||
showCoopSetupPanel(false, false);
|
return;
|
||||||
|
}
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
case SDL_SCANCODE_BACKSPACE:
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
|
||||||
|
if (!target.empty()) target.pop_back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case SDL_SCANCODE_RETURN:
|
case SDL_SCANCODE_RETURN:
|
||||||
case SDL_SCANCODE_KP_ENTER:
|
case SDL_SCANCODE_KP_ENTER:
|
||||||
case SDL_SCANCODE_SPACE:
|
case SDL_SCANCODE_SPACE:
|
||||||
{
|
{
|
||||||
const bool useAI = (coopSetupSelected == 1);
|
// Existing flows (Local 2P / AI) are preserved exactly.
|
||||||
if (ctx.coopVsAI) {
|
if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) {
|
||||||
*ctx.coopVsAI = useAI;
|
const bool useAI = (coopSetupSelected == 1);
|
||||||
}
|
if (ctx.coopVsAI) {
|
||||||
if (ctx.game) {
|
*ctx.coopVsAI = useAI;
|
||||||
ctx.game->setMode(GameMode::Cooperate);
|
}
|
||||||
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
if (ctx.game) {
|
||||||
}
|
ctx.game->setMode(GameMode::Cooperate);
|
||||||
if (ctx.coopGame) {
|
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
}
|
||||||
|
if (ctx.coopGame) {
|
||||||
|
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the panel without restarting menu music; gameplay will take over.
|
||||||
|
showCoopSetupPanel(false, false);
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p",
|
||||||
|
coopSetupSelected,
|
||||||
|
ctx.startPlayTransition ? 1 : 0,
|
||||||
|
(void*)ctx.stateManager);
|
||||||
|
|
||||||
|
if (ctx.startPlayTransition) {
|
||||||
|
ctx.startPlayTransition();
|
||||||
|
} else if (ctx.stateManager) {
|
||||||
|
ctx.stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the panel without restarting menu music; gameplay will take over.
|
// Network flow (new): choose host/join, confirm connection before starting.
|
||||||
showCoopSetupPanel(false, false);
|
if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) {
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager);
|
coopNetworkRoleSelected = 0;
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
if (ctx.startPlayTransition) {
|
coopNetworkStatusText.clear();
|
||||||
ctx.startPlayTransition();
|
if (coopNetworkSession) {
|
||||||
} else if (ctx.stateManager) {
|
coopNetworkSession->shutdown();
|
||||||
ctx.stateManager->setState(AppState::Playing);
|
coopNetworkSession.reset();
|
||||||
|
}
|
||||||
|
buttonFlash = 1.0;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
|
||||||
|
// First, let the user enter the address (bind for host, remote for join).
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkEnterAddress;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||||||
|
SDL_StartTextInput(focusWin);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
|
||||||
|
coopNetworkHandshakeSent = false;
|
||||||
|
coopNetworkStatusText.clear();
|
||||||
|
coopNetworkSession = std::make_unique<NetSession>();
|
||||||
|
|
||||||
|
const uint16_t port = coopNetworkPort;
|
||||||
|
bool ok = false;
|
||||||
|
if (coopNetworkRoleSelected == 0) {
|
||||||
|
const std::string bindIp = coopNetworkBindAddress;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST start bind=%s port=%u", bindIp.c_str(), (unsigned)port);
|
||||||
|
ok = coopNetworkSession->host(bindIp, port);
|
||||||
|
coopNetworkStatusText = ok ? "WAITING FOR PLAYER..." : ("HOST FAILED: " + coopNetworkSession->lastError());
|
||||||
|
} else {
|
||||||
|
const std::string joinIp = coopNetworkJoinAddress.empty() ? std::string("127.0.0.1") : coopNetworkJoinAddress;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] JOIN start remote=%s port=%u", joinIp.c_str(), (unsigned)port);
|
||||||
|
ok = coopNetworkSession->join(joinIp, port);
|
||||||
|
coopNetworkStatusText = ok ? "CONNECTING..." : ("JOIN FAILED: " + coopNetworkSession->lastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
|
||||||
|
SDL_StopTextInput(focusWin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkWaiting;
|
||||||
|
} else {
|
||||||
|
// Stay on role choice screen so user can back out.
|
||||||
|
coopNetworkSession.reset();
|
||||||
|
coopSetupStep = CoopSetupStep::NetworkChooseRole;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While waiting for connection, Enter does nothing.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Allow all other keys to be pressed, but don't let them affect the main menu while coop is open.
|
// Allow other keys, but don't let them affect the main menu while coop is open.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -796,6 +960,15 @@ void MenuState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuState::update(double frameMs) {
|
void MenuState::update(double frameMs) {
|
||||||
|
// Transient network status message (e.g., disconnect) shown on return to menu.
|
||||||
|
if (ctx.coopNetUiStatusRemainingMs > 0.0) {
|
||||||
|
ctx.coopNetUiStatusRemainingMs -= frameMs;
|
||||||
|
if (ctx.coopNetUiStatusRemainingMs <= 0.0) {
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 0.0;
|
||||||
|
ctx.coopNetUiStatusText.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update logo animation counter
|
// Update logo animation counter
|
||||||
GlobalState::instance().logoAnimCounter += frameMs;
|
GlobalState::instance().logoAnimCounter += frameMs;
|
||||||
// Advance options panel animation if active
|
// Advance options panel animation if active
|
||||||
@ -1056,6 +1229,15 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX;
|
float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX;
|
||||||
useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255});
|
useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255});
|
||||||
scoresStartY += (float)tH + 12.0f;
|
scoresStartY += (float)tH + 12.0f;
|
||||||
|
|
||||||
|
if (!ctx.coopNetUiStatusText.empty() && ctx.coopNetUiStatusRemainingMs > 0.0) {
|
||||||
|
float msgScale = 0.75f;
|
||||||
|
int mW = 0, mH = 0;
|
||||||
|
useFont->measure(ctx.coopNetUiStatusText, msgScale, mW, mH);
|
||||||
|
float msgX = (LOGICAL_W - (float)mW) * 0.5f + contentOffsetX;
|
||||||
|
useFont->draw(renderer, msgX, scoresStartY, ctx.coopNetUiStatusText, msgScale, SDL_Color{255, 224, 130, 255});
|
||||||
|
scoresStartY += (float)mH + 10.0f;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
static const std::vector<ScoreEntry> EMPTY_SCORES;
|
static const std::vector<ScoreEntry> EMPTY_SCORES;
|
||||||
const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES;
|
const auto& hs = ctx.scores ? ctx.scores->all() : EMPTY_SCORES;
|
||||||
@ -1358,18 +1540,20 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
// highscores area (not sliding offscreen with the scores).
|
// highscores area (not sliding offscreen with the scores).
|
||||||
const float panelBaseY = scoresStartY - 20.0f;
|
const float panelBaseY = scoresStartY - 20.0f;
|
||||||
|
|
||||||
// Make the choice buttons smaller, add more spacing, and raise them higher
|
// Choice buttons (partner selection) and nested network host/join UI
|
||||||
const float btnW2 = std::min(300.0f, panelW * 0.30f);
|
|
||||||
const float btnH2 = 60.0f;
|
const float btnH2 = 60.0f;
|
||||||
const float gap = 96.0f;
|
const float gap = 34.0f;
|
||||||
// Shift the image and buttons to the right for layout balance (reduced)
|
const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f);
|
||||||
const float shiftX = 20.0f; // move right by 30px (moved 20px left from previous)
|
const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f;
|
||||||
const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f + shiftX;
|
// Shift the image and buttons slightly for layout balance
|
||||||
|
const float shiftX = 20.0f;
|
||||||
|
const float bx = panelBaseX + (panelW - totalChoiceW) * 0.5f + shiftX;
|
||||||
// Move the buttons up by ~80px to sit closer under the logo
|
// Move the buttons up by ~80px to sit closer under the logo
|
||||||
const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f;
|
const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f;
|
||||||
|
|
||||||
coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 };
|
coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 };
|
||||||
coopSetupBtnRects[1] = SDL_FRect{ bx + btnW2 + gap, by, btnW2, btnH2 };
|
coopSetupBtnRects[1] = SDL_FRect{ bx + (btnW2 + gap), by, btnW2, btnH2 };
|
||||||
|
coopSetupBtnRects[2] = SDL_FRect{ bx + (btnW2 + gap) * 2.0f, by, btnW2, btnH2 };
|
||||||
coopSetupRectsValid = true;
|
coopSetupRectsValid = true;
|
||||||
|
|
||||||
SDL_Color bg{ 24, 36, 52, 220 };
|
SDL_Color bg{ 24, 36, 52, 220 };
|
||||||
@ -1392,13 +1576,13 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the image loaded, render it centered above the two choice buttons
|
// If the image loaded, render it centered above the choice buttons
|
||||||
// Compute fade alpha from the coop transition so it can be used for image, text and buttons
|
// Compute fade alpha from the coop transition so it can be used for image, text and buttons
|
||||||
float alphaFactor = static_cast<float>(coopSetupTransition);
|
float alphaFactor = static_cast<float>(coopSetupTransition);
|
||||||
if (alphaFactor < 0.0f) alphaFactor = 0.0f;
|
if (alphaFactor < 0.0f) alphaFactor = 0.0f;
|
||||||
if (alphaFactor > 1.0f) alphaFactor = 1.0f;
|
if (alphaFactor > 1.0f) alphaFactor = 1.0f;
|
||||||
if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) {
|
if (coopInfoTexture && coopInfoTexW > 0 && coopInfoTexH > 0) {
|
||||||
float totalW = btnW2 * 2.0f + gap;
|
float totalW = totalChoiceW;
|
||||||
// Increase allowed image width by ~15% (was 0.75 of totalW)
|
// Increase allowed image width by ~15% (was 0.75 of totalW)
|
||||||
const float scaleFactor = 0.75f * 1.25f; // ~0.8625
|
const float scaleFactor = 0.75f * 1.25f; // ~0.8625
|
||||||
float maxImgW = totalW * scaleFactor;
|
float maxImgW = totalW * scaleFactor;
|
||||||
@ -1479,10 +1663,107 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
float buttonFade = rawBtn * rawBtn;
|
float buttonFade = rawBtn * rawBtn;
|
||||||
SDL_Color bgA = bg; bgA.a = static_cast<Uint8>(std::round(bgA.a * buttonFade));
|
SDL_Color bgA = bg; bgA.a = static_cast<Uint8>(std::round(bgA.a * buttonFade));
|
||||||
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(std::round(borderA.a * buttonFade));
|
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(std::round(borderA.a * buttonFade));
|
||||||
UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[0].x + btnW2 * 0.5f, coopSetupBtnRects[0].y + btnH2 * 0.5f,
|
|
||||||
btnW2, btnH2, "2 PLAYERS", false, coopSetupSelected == 0, bgA, borderA, false, nullptr);
|
// Step 1: choose partner mode
|
||||||
UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[1].x + btnW2 * 0.5f, coopSetupBtnRects[1].y + btnH2 * 0.5f,
|
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
|
||||||
btnW2, btnH2, "COMPUTER (AI)", false, coopSetupSelected == 1, bgA, borderA, false, nullptr);
|
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||||||
|
coopSetupBtnRects[0].x + btnW2 * 0.5f,
|
||||||
|
coopSetupBtnRects[0].y + btnH2 * 0.5f,
|
||||||
|
btnW2, btnH2,
|
||||||
|
"LOCAL CO-OP",
|
||||||
|
false,
|
||||||
|
coopSetupSelected == 0,
|
||||||
|
bgA,
|
||||||
|
borderA,
|
||||||
|
false,
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||||||
|
coopSetupBtnRects[1].x + btnW2 * 0.5f,
|
||||||
|
coopSetupBtnRects[1].y + btnH2 * 0.5f,
|
||||||
|
btnW2, btnH2,
|
||||||
|
"AI PARTNER",
|
||||||
|
false,
|
||||||
|
coopSetupSelected == 1,
|
||||||
|
bgA,
|
||||||
|
borderA,
|
||||||
|
false,
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
UIRenderer::drawButton(renderer, ctx.pixelFont,
|
||||||
|
coopSetupBtnRects[2].x + btnW2 * 0.5f,
|
||||||
|
coopSetupBtnRects[2].y + btnH2 * 0.5f,
|
||||||
|
btnW2, btnH2,
|
||||||
|
"2 PLAYER (NETWORK)",
|
||||||
|
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;
|
||||||
|
const float roleY = by + btnH2 + 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))};
|
||||||
|
char endpoint[256];
|
||||||
|
std::snprintf(endpoint, sizeof(endpoint), "PORT %u HOST IP %s JOIN IP %s",
|
||||||
|
(unsigned)coopNetworkPort,
|
||||||
|
coopNetworkBindAddress.c_str(),
|
||||||
|
coopNetworkJoinAddress.c_str());
|
||||||
|
f->draw(renderer, panelBaseX + 60.0f, roleY + btnH2 + 12.0f, endpoint, 0.90f, infoCol);
|
||||||
|
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkWaiting && !coopNetworkStatusText.empty()) {
|
||||||
|
SDL_Color statusCol{255, 215, 80, static_cast<Uint8>(std::round(240.0f * buttonFade))};
|
||||||
|
f->draw(renderer, panelBaseX + 60.0f, roleY + btnH2 + 44.0f, 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, panelBaseX + 60.0f, roleY + btnH2 + 44.0f, label, 0.82f, hintCol);
|
||||||
|
} else {
|
||||||
|
SDL_Color hintCol{160, 190, 210, static_cast<Uint8>(std::round(200.0f * buttonFade))};
|
||||||
|
f->draw(renderer, panelBaseX + 60.0f, roleY + btnH2 + 44.0f, "PRESS ENTER TO EDIT/CONFIRM ESC TO GO BACK", 0.82f, hintCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// NOTE: slide-up COOP panel intentionally removed. Only the inline
|
// NOTE: slide-up COOP panel intentionally removed. Only the inline
|
||||||
// highscores-area choice buttons are shown when coop setup is active.
|
// highscores-area choice buttons are shown when coop setup is active.
|
||||||
@ -1840,6 +2121,108 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
|
|||||||
{
|
{
|
||||||
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "MenuState::render exit\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Network coop flow polling (non-blocking)
|
||||||
|
if (coopSetupAnimating || coopSetupVisible) {
|
||||||
|
if (coopSetupStep == CoopSetupStep::NetworkWaiting && coopNetworkSession) {
|
||||||
|
coopNetworkSession->poll(0);
|
||||||
|
|
||||||
|
// Update status depending on connection and role.
|
||||||
|
if (!coopNetworkSession->isConnected()) {
|
||||||
|
// Keep existing text (WAITING/CONNECTING) unless an error occurs.
|
||||||
|
} else {
|
||||||
|
// Host sends handshake after peer connects.
|
||||||
|
if (coopNetworkRoleSelected == 0 && !coopNetworkHandshakeSent) {
|
||||||
|
std::random_device rd;
|
||||||
|
uint32_t seed = static_cast<uint32_t>(rd());
|
||||||
|
if (seed == 0u) seed = 1u;
|
||||||
|
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
NetSession::Handshake hs{ seed, 0u, startLevel };
|
||||||
|
if (coopNetworkSession->sendHandshake(hs)) {
|
||||||
|
coopNetworkHandshakeSent = true;
|
||||||
|
ctx.coopNetRngSeed = seed;
|
||||||
|
coopNetworkStatusText = "CONNECTED";
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST handshake sent seed=%u level=%u", seed, (unsigned)startLevel);
|
||||||
|
} else {
|
||||||
|
coopNetworkStatusText = "HANDSHAKE FAILED";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client waits for handshake.
|
||||||
|
if (coopNetworkRoleSelected == 1) {
|
||||||
|
auto hs = coopNetworkSession->takeReceivedHandshake();
|
||||||
|
if (hs.has_value()) {
|
||||||
|
coopNetworkStatusText = "CONNECTED";
|
||||||
|
coopNetworkHandshakeSent = true;
|
||||||
|
ctx.coopNetRngSeed = hs->rngSeed;
|
||||||
|
if (ctx.startLevelSelection) {
|
||||||
|
*ctx.startLevelSelection = static_cast<int>(hs->startLevel);
|
||||||
|
}
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] CLIENT handshake recv seed=%u level=%u", hs->rngSeed, (unsigned)hs->startLevel);
|
||||||
|
} else {
|
||||||
|
coopNetworkStatusText = "CONNECTED - WAITING FOR HOST...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmed connection => start COOPERATE (network) session.
|
||||||
|
// Note: gameplay/network input injection is implemented separately.
|
||||||
|
if (coopNetworkHandshakeSent) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP] START gameplay (role=%s localIsLeft=%d seed=%u level=%u)",
|
||||||
|
(coopNetworkRoleSelected == 0 ? "HOST" : "CLIENT"),
|
||||||
|
(coopNetworkRoleSelected == 0 ? 1 : 0),
|
||||||
|
(unsigned)ctx.coopNetRngSeed,
|
||||||
|
(unsigned)(ctx.startLevelSelection ? *ctx.startLevelSelection : 0));
|
||||||
|
// Hand off the session to gameplay.
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.coopNetEnabled = true;
|
||||||
|
ctx.coopNetIsHost = (coopNetworkRoleSelected == 0);
|
||||||
|
ctx.coopNetLocalIsLeft = (coopNetworkRoleSelected == 0);
|
||||||
|
ctx.coopNetTick = 0;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
ctx.coopNetDesyncDetected = false;
|
||||||
|
|
||||||
|
const uint32_t seed = (ctx.coopNetRngSeed == 0u) ? 1u : ctx.coopNetRngSeed;
|
||||||
|
const uint8_t startLevel = static_cast<uint8_t>(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
|
||||||
|
if (ctx.coopVsAI) {
|
||||||
|
*ctx.coopVsAI = false;
|
||||||
|
}
|
||||||
|
if (ctx.game) {
|
||||||
|
ctx.game->setMode(GameMode::Cooperate);
|
||||||
|
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
|
||||||
|
}
|
||||||
|
if (ctx.coopGame) {
|
||||||
|
// Deterministic reset for network coop.
|
||||||
|
ctx.coopGame->resetDeterministic(startLevel, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer ownership of the active session.
|
||||||
|
ctx.coopNetSession = std::move(coopNetworkSession);
|
||||||
|
|
||||||
|
// Close the panel without restarting menu music; gameplay will take over.
|
||||||
|
showCoopSetupPanel(false, false);
|
||||||
|
|
||||||
|
// For network lockstep, do NOT run the menu->play countdown/fade.
|
||||||
|
// Any local countdown introduces drift and stalls.
|
||||||
|
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||||||
|
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||||||
|
if (ctx.game) ctx.game->setPaused(false);
|
||||||
|
|
||||||
|
if (ctx.stateManager) {
|
||||||
|
ctx.stateManager->setState(AppState::Playing);
|
||||||
|
} else if (ctx.startPlayTransition) {
|
||||||
|
// Fallback if state manager is unavailable.
|
||||||
|
ctx.startPlayTransition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "State.h"
|
#include "State.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class NetSession;
|
||||||
|
|
||||||
class MenuState : public State {
|
class MenuState : public State {
|
||||||
public:
|
public:
|
||||||
MenuState(StateContext& ctx);
|
MenuState(StateContext& ctx);
|
||||||
@ -105,8 +111,27 @@ private:
|
|||||||
double coopSetupTransition = 0.0; // 0..1
|
double coopSetupTransition = 0.0; // 0..1
|
||||||
double coopSetupTransitionDurationMs = 320.0;
|
double coopSetupTransitionDurationMs = 320.0;
|
||||||
int coopSetupDirection = 1; // 1 show, -1 hide
|
int coopSetupDirection = 1; // 1 show, -1 hide
|
||||||
int coopSetupSelected = 0; // 0 = 2 players, 1 = AI
|
// 0 = Local co-op (2 players), 1 = AI partner, 2 = 2 player (network)
|
||||||
SDL_FRect coopSetupBtnRects[2]{};
|
int coopSetupSelected = 0;
|
||||||
|
|
||||||
|
enum class CoopSetupStep {
|
||||||
|
ChoosePartner,
|
||||||
|
NetworkChooseRole,
|
||||||
|
NetworkEnterAddress,
|
||||||
|
NetworkWaiting,
|
||||||
|
};
|
||||||
|
CoopSetupStep coopSetupStep = CoopSetupStep::ChoosePartner;
|
||||||
|
|
||||||
|
// Network sub-flow state (only used when coopSetupSelected == 2)
|
||||||
|
int coopNetworkRoleSelected = 0; // 0 = host, 1 = join
|
||||||
|
std::string coopNetworkBindAddress = "0.0.0.0";
|
||||||
|
std::string coopNetworkJoinAddress = "127.0.0.1";
|
||||||
|
uint16_t coopNetworkPort = 7777;
|
||||||
|
bool coopNetworkHandshakeSent = false;
|
||||||
|
std::string coopNetworkStatusText;
|
||||||
|
std::unique_ptr<NetSession> coopNetworkSession;
|
||||||
|
|
||||||
|
SDL_FRect coopSetupBtnRects[3]{};
|
||||||
bool coopSetupRectsValid = false;
|
bool coopSetupRectsValid = false;
|
||||||
// Optional cooperative info image shown when coop setup panel is active
|
// Optional cooperative info image shown when coop setup panel is active
|
||||||
SDL_Texture* coopInfoTexture = nullptr;
|
SDL_Texture* coopInfoTexture = nullptr;
|
||||||
|
|||||||
@ -6,9 +6,11 @@
|
|||||||
#include "../persistence/Scores.h"
|
#include "../persistence/Scores.h"
|
||||||
#include "../audio/Audio.h"
|
#include "../audio/Audio.h"
|
||||||
#include "../audio/SoundEffect.h"
|
#include "../audio/SoundEffect.h"
|
||||||
|
#include "../graphics/Font.h"
|
||||||
#include "../graphics/renderers/GameRenderer.h"
|
#include "../graphics/renderers/GameRenderer.h"
|
||||||
#include "../core/Settings.h"
|
#include "../core/Settings.h"
|
||||||
#include "../core/Config.h"
|
#include "../core/Config.h"
|
||||||
|
#include "../network/CoopNetButtons.h"
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
// File-scope transport/spawn detection state
|
// File-scope transport/spawn detection state
|
||||||
@ -24,9 +26,17 @@ void PlayingState::onEnter() {
|
|||||||
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
|
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
|
||||||
if (ctx.startLevelSelection) {
|
if (ctx.startLevelSelection) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||||
ctx.game->reset(*ctx.startLevelSelection);
|
const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession;
|
||||||
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
|
||||||
ctx.coopGame->reset(*ctx.startLevelSelection);
|
// For network co-op, MenuState already performed a deterministic reset using the negotiated seed.
|
||||||
|
// Re-resetting here would overwrite it (and will desync).
|
||||||
|
if (!coopNetActive) {
|
||||||
|
ctx.game->reset(*ctx.startLevelSelection);
|
||||||
|
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
||||||
|
ctx.coopGame->reset(*ctx.startLevelSelection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.game->setPaused(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -46,6 +56,18 @@ void PlayingState::onExit() {
|
|||||||
SDL_DestroyTexture(m_renderTarget);
|
SDL_DestroyTexture(m_renderTarget);
|
||||||
m_renderTarget = nullptr;
|
m_renderTarget = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we are leaving gameplay during network co-op, tear down the session so
|
||||||
|
// hosting/joining again works without restarting the app.
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
ctx.coopNetDesyncDetected = false;
|
||||||
|
ctx.coopNetTick = 0;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayingState::handleEvent(const SDL_Event& e) {
|
void PlayingState::handleEvent(const SDL_Event& e) {
|
||||||
@ -135,6 +157,10 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
|
|
||||||
// Pause toggle (P) - matches classic behavior; disabled during countdown
|
// Pause toggle (P) - matches classic behavior; disabled during countdown
|
||||||
if (e.key.scancode == SDL_SCANCODE_P) {
|
if (e.key.scancode == SDL_SCANCODE_P) {
|
||||||
|
// Network co-op uses lockstep; local pause would desync/stall the peer.
|
||||||
|
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) ||
|
const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) ||
|
||||||
(ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed);
|
(ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed);
|
||||||
if (!countdown) {
|
if (!countdown) {
|
||||||
@ -149,6 +175,49 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (coopActive && ctx.coopGame) {
|
if (coopActive && ctx.coopGame) {
|
||||||
|
// Network co-op: route one-shot actions into a pending bitmask for lockstep.
|
||||||
|
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||||
|
const bool localIsLeft = ctx.coopNetLocalIsLeft;
|
||||||
|
const SDL_Scancode sc = e.key.scancode;
|
||||||
|
if (localIsLeft) {
|
||||||
|
if (sc == SDL_SCANCODE_W) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::RotCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_Q) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::RotCCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_LSHIFT || sc == SDL_SCANCODE_E) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::HardDrop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_LCTRL) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::Hold;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sc == SDL_SCANCODE_UP) {
|
||||||
|
const bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
|
ctx.coopNetPendingButtons |= upIsCW ? coopnet::RotCW : coopnet::RotCCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_RALT) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::RotCCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_SPACE || sc == SDL_SCANCODE_RSHIFT) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::HardDrop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_RCTRL) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::Hold;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If coopNet is active, suppress local co-op direct action keys.
|
||||||
|
}
|
||||||
|
|
||||||
const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
|
const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
|
||||||
|
|
||||||
// Player 1 (left): when AI is enabled it controls the left side so
|
// Player 1 (left): when AI is enabled it controls the left side so
|
||||||
@ -313,6 +382,31 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
// But countdown should definitely NOT show the "PAUSED" overlay.
|
// But countdown should definitely NOT show the "PAUSED" overlay.
|
||||||
bool shouldBlur = paused && !countdown && !challengeClearFx;
|
bool shouldBlur = paused && !countdown && !challengeClearFx;
|
||||||
|
|
||||||
|
auto renderNetOverlay = [&]() {
|
||||||
|
if (!coopActive || !ctx.coopNetEnabled || !ctx.pixelFont) return;
|
||||||
|
if (!ctx.coopNetDesyncDetected && !ctx.coopNetStalled) return;
|
||||||
|
|
||||||
|
const char* text = ctx.coopNetDesyncDetected ? "NET: DESYNC" : "NET: STALLED";
|
||||||
|
SDL_Color textColor = ctx.coopNetDesyncDetected ? SDL_Color{255, 230, 180, 255} : SDL_Color{255, 224, 130, 255};
|
||||||
|
float scale = 0.75f;
|
||||||
|
int tw = 0, th = 0;
|
||||||
|
ctx.pixelFont->measure(text, scale, tw, th);
|
||||||
|
|
||||||
|
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||||
|
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
|
||||||
|
const float pad = 8.0f;
|
||||||
|
const float x = 18.0f;
|
||||||
|
const float y = 14.0f;
|
||||||
|
SDL_FRect bg{ x - pad, y - pad, (float)tw + pad * 2.0f, (float)th + pad * 2.0f };
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
|
||||||
|
SDL_RenderFillRect(renderer, &bg);
|
||||||
|
ctx.pixelFont->draw(renderer, x, y, text, scale, textColor);
|
||||||
|
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, prevBlend);
|
||||||
|
};
|
||||||
|
|
||||||
if (shouldBlur && m_renderTarget) {
|
if (shouldBlur && m_renderTarget) {
|
||||||
// Render game to texture
|
// Render game to texture
|
||||||
SDL_SetRenderTarget(renderer, m_renderTarget);
|
SDL_SetRenderTarget(renderer, m_renderTarget);
|
||||||
@ -421,6 +515,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
SDL_SetRenderViewport(renderer, &oldVP);
|
SDL_SetRenderViewport(renderer, &oldVP);
|
||||||
SDL_SetRenderScale(renderer, oldSX, oldSY);
|
SDL_SetRenderScale(renderer, oldSX, oldSY);
|
||||||
|
|
||||||
|
// Net overlay (on top of blurred game, under pause/exit overlays)
|
||||||
|
renderNetOverlay();
|
||||||
|
|
||||||
// Draw overlays
|
// Draw overlays
|
||||||
if (exitPopup) {
|
if (exitPopup) {
|
||||||
GameRenderer::renderExitPopup(
|
GameRenderer::renderExitPopup(
|
||||||
@ -466,6 +563,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
(float)winW,
|
(float)winW,
|
||||||
(float)winH
|
(float)winH
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Net overlay (on top of coop HUD)
|
||||||
|
renderNetOverlay();
|
||||||
} else {
|
} else {
|
||||||
GameRenderer::renderPlayingState(
|
GameRenderer::renderPlayingState(
|
||||||
renderer,
|
renderer,
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "../network/NetSession.h"
|
||||||
|
|
||||||
// Forward declarations for frequently used types
|
// Forward declarations for frequently used types
|
||||||
class Game;
|
class Game;
|
||||||
@ -81,6 +84,20 @@ struct StateContext {
|
|||||||
std::string* playerName = nullptr; // Shared player name buffer for highscores/options
|
std::string* playerName = nullptr; // Shared player name buffer for highscores/options
|
||||||
// Coop setting: when true, COOPERATE runs with a computer-controlled right player.
|
// Coop setting: when true, COOPERATE runs with a computer-controlled right player.
|
||||||
bool* coopVsAI = nullptr;
|
bool* coopVsAI = nullptr;
|
||||||
|
|
||||||
|
// COOPERATE (network) --------------------------------------------------
|
||||||
|
// These fields are only meaningful when `coopNetEnabled` is true.
|
||||||
|
bool coopNetEnabled = false;
|
||||||
|
bool coopNetIsHost = false;
|
||||||
|
bool coopNetLocalIsLeft = true; // host = left (WASD), client = right (arrows)
|
||||||
|
uint32_t coopNetRngSeed = 0;
|
||||||
|
uint32_t coopNetTick = 0;
|
||||||
|
uint8_t coopNetPendingButtons = 0; // one-shot actions captured from keydown (rotate/hold/harddrop)
|
||||||
|
bool coopNetStalled = false; // true when waiting for remote input for current tick
|
||||||
|
bool coopNetDesyncDetected = false;
|
||||||
|
std::string coopNetUiStatusText; // transient status shown in menu after net abort
|
||||||
|
double coopNetUiStatusRemainingMs = 0.0;
|
||||||
|
std::unique_ptr<NetSession> coopNetSession;
|
||||||
bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available
|
bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available
|
||||||
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
|
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
|
||||||
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable
|
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"name": "sdl3-image",
|
"name": "sdl3-image",
|
||||||
"features": ["jpeg", "png", "webp"]
|
"features": ["jpeg", "png", "webp"]
|
||||||
},
|
},
|
||||||
|
"enet",
|
||||||
"catch2",
|
"catch2",
|
||||||
"cpr",
|
"cpr",
|
||||||
"nlohmann-json"
|
"nlohmann-json"
|
||||||
|
|||||||
Reference in New Issue
Block a user