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

@ -1,6 +1,7 @@
#include "MenuState.h"
#include "persistence/Scores.h"
#include "../network/supabase_client.h"
#include "../network/NetSession.h"
#include "graphics/Font.h"
#include "../graphics/ui/HelpOverlay.h"
#include "../core/GlobalState.h"
@ -16,6 +17,7 @@
#include <array>
#include <cmath>
#include <vector>
#include <random>
// Use dynamic logical dimensions from GlobalState instead of hardcoded values
// This allows the UI to adapt when the window is resized or goes fullscreen
@ -141,6 +143,17 @@ void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
coopSetupAnimating = true;
coopSetupDirection = 1;
coopSetupSelected = (ctx.coopVsAI && *ctx.coopVsAI) ? 1 : 0;
coopSetupStep = CoopSetupStep::ChoosePartner;
coopNetworkRoleSelected = 0;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
coopSetupRectsValid = false;
selectedButton = static_cast<int>(ui::BottomMenuItem::Cooperate);
// Ensure the transition value is non-zero so render code can show
@ -152,6 +165,19 @@ void MenuState::showCoopSetupPanel(bool show, bool resumeMusic) {
coopSetupAnimating = true;
coopSetupDirection = -1;
coopSetupRectsValid = false;
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
// Cancel any pending network session if the coop setup is being closed.
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopSetupStep = CoopSetupStep::ChoosePartner;
// Resume menu music only when requested (ESC should pass resumeMusic=false)
if (resumeMusic && ctx.musicEnabled && *ctx.musicEnabled) {
Audio::instance().playMenuMusic();
@ -280,58 +306,196 @@ void MenuState::onExit() {
}
void MenuState::handleEvent(const SDL_Event& e) {
// Text input for network IP entry (only while coop setup panel is active).
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_TEXT_INPUT) {
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
if (target.size() < 64) {
target += e.text.text;
}
return;
}
}
// Coop setup panel navigation (modal within the menu)
// Handle this FIRST and consume key events so the main menu navigation doesn't interfere.
// Note: Do not require !repeat here; some keyboards/OS configs may emit Enter with repeat.
if ((coopSetupVisible || coopSetupAnimating) && coopSetupTransition > 0.0 && e.type == SDL_EVENT_KEY_DOWN) {
// Coop setup panel navigation (modal within the menu)
switch (e.key.scancode) {
case SDL_SCANCODE_UP:
case SDL_SCANCODE_DOWN:
// Do NOT allow up/down to change anything while this panel is active
return;
case SDL_SCANCODE_ESCAPE:
// When in a nested network step, go back one step; otherwise close the panel.
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopSetupStep = CoopSetupStep::ChoosePartner;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
coopSetupStep = CoopSetupStep::NetworkChooseRole;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
return;
}
if (coopSetupStep == CoopSetupStep::NetworkWaiting) {
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopSetupStep = CoopSetupStep::NetworkChooseRole;
return;
}
showCoopSetupPanel(false, false);
return;
case SDL_SCANCODE_LEFT:
case SDL_SCANCODE_A:
coopSetupSelected = 0;
buttonFlash = 1.0;
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
// 3-way selection: LOCAL / AI / NETWORK
coopSetupSelected = (coopSetupSelected + 3 - 1) % 3;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopNetworkRoleSelected = (coopNetworkRoleSelected + 2 - 1) % 2;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
return;
}
return;
case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_D:
coopSetupSelected = 1;
buttonFlash = 1.0;
return;
// Do NOT allow up/down to change anything
case SDL_SCANCODE_UP:
case SDL_SCANCODE_DOWN:
return;
case SDL_SCANCODE_ESCAPE:
showCoopSetupPanel(false, false);
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
coopSetupSelected = (coopSetupSelected + 1) % 3;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
coopNetworkRoleSelected = (coopNetworkRoleSelected + 1) % 2;
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
return;
}
return;
case SDL_SCANCODE_BACKSPACE:
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
std::string& target = (coopNetworkRoleSelected == 0) ? coopNetworkBindAddress : coopNetworkJoinAddress;
if (!target.empty()) target.pop_back();
return;
}
break;
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
case SDL_SCANCODE_SPACE:
{
const bool useAI = (coopSetupSelected == 1);
if (ctx.coopVsAI) {
*ctx.coopVsAI = useAI;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
// Existing flows (Local 2P / AI) are preserved exactly.
if (coopSetupStep == CoopSetupStep::ChoosePartner && (coopSetupSelected == 0 || coopSetupSelected == 1)) {
const bool useAI = (coopSetupSelected == 1);
if (ctx.coopVsAI) {
*ctx.coopVsAI = useAI;
}
if (ctx.game) {
ctx.game->setMode(GameMode::Cooperate);
ctx.game->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
if (ctx.coopGame) {
ctx.coopGame->reset(ctx.startLevelSelection ? *ctx.startLevelSelection : 0);
}
// Close the panel without restarting menu music; gameplay will take over.
showCoopSetupPanel(false, false);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p",
coopSetupSelected,
ctx.startPlayTransition ? 1 : 0,
(void*)ctx.stateManager);
if (ctx.startPlayTransition) {
ctx.startPlayTransition();
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
}
return;
}
// Close the panel without restarting menu music; gameplay will take over.
showCoopSetupPanel(false, false);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "MenuState: coop start via key, selected=%d, startPlayTransition_present=%d, stateManager=%p", coopSetupSelected, ctx.startPlayTransition ? 1 : 0, (void*)ctx.stateManager);
if (ctx.startPlayTransition) {
ctx.startPlayTransition();
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Playing);
// Network flow (new): choose host/join, confirm connection before starting.
if (coopSetupStep == CoopSetupStep::ChoosePartner && coopSetupSelected == 2) {
coopSetupStep = CoopSetupStep::NetworkChooseRole;
coopNetworkRoleSelected = 0;
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
if (coopNetworkSession) {
coopNetworkSession->shutdown();
coopNetworkSession.reset();
}
buttonFlash = 1.0;
return;
}
if (coopSetupStep == CoopSetupStep::NetworkChooseRole) {
// First, let the user enter the address (bind for host, remote for join).
coopSetupStep = CoopSetupStep::NetworkEnterAddress;
coopNetworkStatusText.clear();
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StartTextInput(focusWin);
}
return;
}
if (coopSetupStep == CoopSetupStep::NetworkEnterAddress) {
coopNetworkHandshakeSent = false;
coopNetworkStatusText.clear();
coopNetworkSession = std::make_unique<NetSession>();
const uint16_t port = coopNetworkPort;
bool ok = false;
if (coopNetworkRoleSelected == 0) {
const std::string bindIp = coopNetworkBindAddress;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] HOST start bind=%s port=%u", bindIp.c_str(), (unsigned)port);
ok = coopNetworkSession->host(bindIp, port);
coopNetworkStatusText = ok ? "WAITING FOR PLAYER..." : ("HOST FAILED: " + coopNetworkSession->lastError());
} else {
const std::string joinIp = coopNetworkJoinAddress.empty() ? std::string("127.0.0.1") : coopNetworkJoinAddress;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] JOIN start remote=%s port=%u", joinIp.c_str(), (unsigned)port);
ok = coopNetworkSession->join(joinIp, port);
coopNetworkStatusText = ok ? "CONNECTING..." : ("JOIN FAILED: " + coopNetworkSession->lastError());
}
if (SDL_Window* focusWin = SDL_GetKeyboardFocus()) {
SDL_StopTextInput(focusWin);
}
if (ok) {
coopSetupStep = CoopSetupStep::NetworkWaiting;
} else {
// Stay on role choice screen so user can back out.
coopNetworkSession.reset();
coopSetupStep = CoopSetupStep::NetworkChooseRole;
}
return;
}
// While waiting for connection, Enter does nothing.
return;
}
default:
// Allow all other keys to be pressed, but don't let them affect the main menu while coop is open.
// Allow other keys, but don't let them affect the main menu while coop is open.
return;
}
}
@ -796,6 +960,15 @@ void MenuState::handleEvent(const SDL_Event& e) {
}
void MenuState::update(double frameMs) {
// Transient network status message (e.g., disconnect) shown on return to menu.
if (ctx.coopNetUiStatusRemainingMs > 0.0) {
ctx.coopNetUiStatusRemainingMs -= frameMs;
if (ctx.coopNetUiStatusRemainingMs <= 0.0) {
ctx.coopNetUiStatusRemainingMs = 0.0;
ctx.coopNetUiStatusText.clear();
}
}
// Update logo animation counter
GlobalState::instance().logoAnimCounter += frameMs;
// Advance options panel animation if active
@ -1056,6 +1229,15 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
float titleX = (LOGICAL_W - (float)tW) * 0.5f + contentOffsetX;
useFont->draw(renderer, titleX, scoresStartY, smallTitle, titleScale, SDL_Color{200,220,230,255});
scoresStartY += (float)tH + 12.0f;
if (!ctx.coopNetUiStatusText.empty() && ctx.coopNetUiStatusRemainingMs > 0.0) {
float msgScale = 0.75f;
int mW = 0, mH = 0;
useFont->measure(ctx.coopNetUiStatusText, msgScale, mW, mH);
float msgX = (LOGICAL_W - (float)mW) * 0.5f + contentOffsetX;
useFont->draw(renderer, msgX, scoresStartY, ctx.coopNetUiStatusText, msgScale, SDL_Color{255, 224, 130, 255});
scoresStartY += (float)mH + 10.0f;
}
}
static const std::vector<ScoreEntry> 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).
const float panelBaseY = scoresStartY - 20.0f;
// Make the choice buttons smaller, add more spacing, and raise them higher
const float btnW2 = std::min(300.0f, panelW * 0.30f);
// Choice buttons (partner selection) and nested network host/join UI
const float btnH2 = 60.0f;
const float gap = 96.0f;
// Shift the image and buttons to the right for layout balance (reduced)
const float shiftX = 20.0f; // move right by 30px (moved 20px left from previous)
const float bx = panelBaseX + (panelW - (btnW2 * 2.0f + gap)) * 0.5f + shiftX;
const float gap = 34.0f;
const float btnW2 = std::min(280.0f, (panelW - gap * 2.0f) / 3.0f);
const float totalChoiceW = btnW2 * 3.0f + gap * 2.0f;
// Shift the image and buttons slightly for layout balance
const float shiftX = 20.0f;
const float bx = panelBaseX + (panelW - totalChoiceW) * 0.5f + shiftX;
// Move the buttons up by ~80px to sit closer under the logo
const float by = panelBaseY + (panelH - btnH2) * 0.5f - 80.0f;
coopSetupBtnRects[0] = SDL_FRect{ bx, by, btnW2, btnH2 };
coopSetupBtnRects[1] = SDL_FRect{ bx + btnW2 + gap, by, btnW2, btnH2 };
coopSetupBtnRects[1] = SDL_FRect{ bx + (btnW2 + gap), by, btnW2, btnH2 };
coopSetupBtnRects[2] = SDL_FRect{ bx + (btnW2 + gap) * 2.0f, by, btnW2, btnH2 };
coopSetupRectsValid = true;
SDL_Color bg{ 24, 36, 52, 220 };
@ -1392,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
float alphaFactor = static_cast<float>(coopSetupTransition);
if (alphaFactor < 0.0f) alphaFactor = 0.0f;
if (alphaFactor > 1.0f) alphaFactor = 1.0f;
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)
const float scaleFactor = 0.75f * 1.25f; // ~0.8625
float maxImgW = totalW * scaleFactor;
@ -1479,10 +1663,107 @@ void MenuState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logi
float buttonFade = rawBtn * rawBtn;
SDL_Color bgA = bg; bgA.a = static_cast<Uint8>(std::round(bgA.a * buttonFade));
SDL_Color borderA = border; borderA.a = static_cast<Uint8>(std::round(borderA.a * buttonFade));
UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[0].x + btnW2 * 0.5f, coopSetupBtnRects[0].y + btnH2 * 0.5f,
btnW2, btnH2, "2 PLAYERS", false, coopSetupSelected == 0, bgA, borderA, false, nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont, coopSetupBtnRects[1].x + btnW2 * 0.5f, coopSetupBtnRects[1].y + btnH2 * 0.5f,
btnW2, btnH2, "COMPUTER (AI)", false, coopSetupSelected == 1, bgA, borderA, false, nullptr);
// Step 1: choose partner mode
if (coopSetupStep == CoopSetupStep::ChoosePartner) {
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[0].x + btnW2 * 0.5f,
coopSetupBtnRects[0].y + btnH2 * 0.5f,
btnW2, btnH2,
"LOCAL CO-OP",
false,
coopSetupSelected == 0,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[1].x + btnW2 * 0.5f,
coopSetupBtnRects[1].y + btnH2 * 0.5f,
btnW2, btnH2,
"AI PARTNER",
false,
coopSetupSelected == 1,
bgA,
borderA,
false,
nullptr);
UIRenderer::drawButton(renderer, ctx.pixelFont,
coopSetupBtnRects[2].x + btnW2 * 0.5f,
coopSetupBtnRects[2].y + btnH2 * 0.5f,
btnW2, btnH2,
"2 PLAYER (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
// 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); }
}
// 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();
}
}
}
}
}
}