added basic network play

This commit is contained in:
2025-12-23 19:03:33 +01:00
parent 5ec4bf926b
commit a7a3ae9055
14 changed files with 1387 additions and 62 deletions

View File

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