diff --git a/ISLE/isleapp.cpp b/ISLE/isleapp.cpp index bfc3df23..9440fa8e 100644 --- a/ISLE/isleapp.cpp +++ b/ISLE/isleapp.cpp @@ -1300,7 +1300,7 @@ inline bool IsleApp::Tick() } #ifdef EXTENSIONS - if (Extensions::IsMultiplayerDisconnected()) { + if (Extensions::IsMultiplayerRejected()) { g_closed = TRUE; return true; } diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h index cd087c3f..0d3078f5 100644 --- a/extensions/include/extensions/multiplayer.h +++ b/extensions/include/extensions/multiplayer.h @@ -45,7 +45,7 @@ class MultiplayerExt { static MxBool IsClonedCharacter(const char* p_name); static void HandleBeforeSaveLoad(); static void HandleSaveLoaded(); - static MxBool CheckDisconnected(); + static MxBool CheckRejected(); static Multiplayer::NetworkManager* GetNetworkManager(); @@ -58,7 +58,7 @@ class MultiplayerExt { }; #ifdef EXTENSIONS -LEGO1_EXPORT bool IsMultiplayerDisconnected(); +LEGO1_EXPORT bool IsMultiplayerRejected(); #endif namespace MP @@ -72,7 +72,7 @@ constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick; constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad; constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded; -constexpr auto CheckDisconnected = &MultiplayerExt::CheckDisconnected; +constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; #else constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; @@ -82,7 +82,7 @@ constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr; constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr; constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr; constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr; -constexpr decltype(&MultiplayerExt::CheckDisconnected) CheckDisconnected = nullptr; +constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; #endif } // namespace MP diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 3ed806a0..9e533c60 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -30,6 +30,12 @@ class NameBubbleRenderer; class NetworkManager : public MxCore { public: + enum ConnectionState { + STATE_DISCONNECTED, + STATE_CONNECTED, + STATE_RECONNECTING + }; + NetworkManager(); ~NetworkManager() override; @@ -49,7 +55,7 @@ class NetworkManager : public MxCore { void Connect(const char* p_roomId); void Disconnect(); bool IsConnected() const; - bool WasDisconnected() const; + bool WasRejected() const; void SetWalkAnimation(uint8_t p_walkAnimId); void SetIdleAnimation(uint8_t p_idleAnimId); @@ -114,6 +120,10 @@ class NetworkManager : public MxCore { void RemoveRemotePlayer(uint32_t p_peerId); void RemoveAllRemotePlayers(); + void CheckConnectionState(); + void AttemptReconnect(); + void ResetStateAfterReconnect(); + void NotifyPlayerCountChanged(); void EnforceDisableNPCs(); @@ -149,8 +159,18 @@ class NetworkManager : public MxCore { bool m_lastCameraEnabled; bool m_wasInRestrictedArea; - static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz - static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout + ConnectionState m_connectionState; + bool m_wasRejected; + std::string m_roomId; + uint32_t m_reconnectAttempt; + uint32_t m_reconnectDelay; + uint32_t m_nextReconnectTime; + + static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz + static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout + static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000; + static const uint32_t RECONNECT_MAX_DELAY_MS = 30000; + static const uint32_t RECONNECT_MAX_ATTEMPTS = 10; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/networktransport.h b/extensions/include/extensions/multiplayer/networktransport.h index 1a33f119..022969e8 100644 --- a/extensions/include/extensions/multiplayer/networktransport.h +++ b/extensions/include/extensions/multiplayer/networktransport.h @@ -7,9 +7,6 @@ namespace Multiplayer { -static const int EXIT_ROOM_FULL = 10; -static const int EXIT_CONNECTION_LOST = 11; - class NetworkTransport { public: virtual ~NetworkTransport() = default; diff --git a/extensions/include/extensions/multiplayer/platformcallbacks.h b/extensions/include/extensions/multiplayer/platformcallbacks.h index 5b8753e9..7004e948 100644 --- a/extensions/include/extensions/multiplayer/platformcallbacks.h +++ b/extensions/include/extensions/multiplayer/platformcallbacks.h @@ -3,6 +3,11 @@ namespace Multiplayer { +static const int CONNECTION_STATUS_CONNECTED = 0; +static const int CONNECTION_STATUS_RECONNECTING = 1; +static const int CONNECTION_STATUS_FAILED = 2; +static const int CONNECTION_STATUS_REJECTED = 3; + class PlatformCallbacks { public: virtual ~PlatformCallbacks() = default; @@ -19,6 +24,9 @@ class PlatformCallbacks { // Called when the allow-customization setting changes. virtual void OnAllowCustomizeChanged(bool p_enabled) = 0; + + // Called when the connection status changes (connected, reconnecting, failed). + virtual void OnConnectionStatusChanged(int p_status) = 0; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h index 5d3d2eca..34e4ac19 100644 --- a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h @@ -13,6 +13,7 @@ class EmscriptenCallbacks : public PlatformCallbacks { void OnThirdPersonChanged(bool p_enabled) override; void OnNameBubblesChanged(bool p_enabled) override; void OnAllowCustomizeChanged(bool p_enabled) override; + void OnConnectionStatusChanged(int p_status) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h index 50d305ba..fd5428a0 100644 --- a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h @@ -13,6 +13,7 @@ class NativeCallbacks : public PlatformCallbacks { void OnThirdPersonChanged(bool p_enabled) override; void OnNameBubblesChanged(bool p_enabled) override; void OnAllowCustomizeChanged(bool p_enabled) override; + void OnConnectionStatusChanged(int p_status) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/worldstatesync.h b/extensions/include/extensions/multiplayer/worldstatesync.h index 6c55db31..5f53f30a 100644 --- a/extensions/include/extensions/multiplayer/worldstatesync.h +++ b/extensions/include/extensions/multiplayer/worldstatesync.h @@ -48,6 +48,9 @@ class WorldStateSync { // Returns TRUE if the local action should be suppressed (non-host). MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType); + // Resets session state for reconnection (peer ID, sequence, host, pending events). + void ResetForReconnect(); + private: void ApplySkyLightState(const char* p_skyColor, int p_lightPos); void SendSnapshotRequest(); diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index 7b29ded3..de989aaa 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -251,9 +251,9 @@ MxBool MultiplayerExt::IsClonedCharacter(const char* p_name) return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE; } -MxBool MultiplayerExt::CheckDisconnected() +MxBool MultiplayerExt::CheckRejected() { - if (s_networkManager && s_networkManager->WasDisconnected()) { + if (s_networkManager && s_networkManager->WasRejected()) { return TRUE; } @@ -265,7 +265,7 @@ Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager() return s_networkManager; } -bool Extensions::IsMultiplayerDisconnected() +bool Extensions::IsMultiplayerRejected() { - return Extension::Call(MP::CheckDisconnected).value_or(FALSE); + return Extension::Call(MP::CheckRejected).value_or(FALSE); } diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 29586c85..1244fde9 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -49,7 +49,9 @@ NetworkManager::NetworkManager() m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true), m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false), - m_disableAllNPCs(false), m_showNameBubbles(true), m_lastCameraEnabled(false), m_wasInRestrictedArea(false) + m_disableAllNPCs(false), m_showNameBubbles(true), m_lastCameraEnabled(false), m_wasInRestrictedArea(false), + m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), m_reconnectDelay(0), + m_nextReconnectTime(0) { } @@ -66,6 +68,7 @@ static ThirdPersonCamera::Controller* GetCamera() MxResult NetworkManager::Tickle() { ProcessPendingRequests(); + CheckConnectionState(); if (m_disableAllNPCs) { EnforceDisableNPCs(); @@ -171,12 +174,15 @@ void NetworkManager::Shutdown() void NetworkManager::Connect(const char* p_roomId) { if (m_transport) { + m_roomId = p_roomId; m_transport->Connect(p_roomId); + m_connectionState = STATE_CONNECTED; } } void NetworkManager::Disconnect() { + m_connectionState = STATE_DISCONNECTED; if (m_transport) { m_transport->Disconnect(); } @@ -188,9 +194,9 @@ bool NetworkManager::IsConnected() const return m_transport && m_transport->IsConnected(); } -bool NetworkManager::WasDisconnected() const +bool NetworkManager::WasRejected() const { - return m_transport && m_transport->WasDisconnected(); + return m_wasRejected; } void NetworkManager::OnWorldEnabled(LegoWorld* p_world) @@ -322,6 +328,88 @@ void NetworkManager::EnforceDisableNPCs() } } +void NetworkManager::CheckConnectionState() +{ + if (!m_transport || m_connectionState == STATE_DISCONNECTED) { + return; + } + + if (m_connectionState == STATE_CONNECTED) { + if (!m_transport->WasDisconnected()) { + return; + } + + if (m_transport->WasRejected()) { + // Room full on initial connect - flag for game loop to exit + m_wasRejected = true; + m_connectionState = STATE_DISCONNECTED; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_REJECTED); + } + return; + } + + // Connection lost - enter reconnection + m_connectionState = STATE_RECONNECTING; + RemoveAllRemotePlayers(); + m_reconnectAttempt = 0; + m_reconnectDelay = RECONNECT_INITIAL_DELAY_MS; + m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_RECONNECTING); + } + return; + } + + // STATE_RECONNECTING + if (m_transport->IsConnected()) { + ResetStateAfterReconnect(); + m_connectionState = STATE_CONNECTED; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_CONNECTED); + } + return; + } + + uint32_t now = SDL_GetTicks(); + if (now < m_nextReconnectTime) { + return; + } + + if (m_reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) { + // Give up - stay alive but without multiplayer + m_connectionState = STATE_DISCONNECTED; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_FAILED); + } + return; + } + + AttemptReconnect(); +} + +void NetworkManager::AttemptReconnect() +{ + m_reconnectAttempt++; + m_transport->Disconnect(); + m_transport->Connect(m_roomId.c_str()); + m_reconnectDelay = SDL_min(m_reconnectDelay * 2, RECONNECT_MAX_DELAY_MS); + m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay; +} + +void NetworkManager::ResetStateAfterReconnect() +{ + m_localPeerId = 0; + m_hostPeerId = 0; + m_sequence = 0; + m_lastBroadcastTime = 0; + m_worldSync.ResetForReconnect(); +} + void NetworkManager::ProcessPendingRequests() { ThirdPersonCamera::Controller* cam = GetCamera(); diff --git a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp index 81f172b3..2f055649 100644 --- a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp @@ -50,6 +50,20 @@ void EmscriptenCallbacks::OnAllowCustomizeChanged(bool p_enabled) DispatchBoolEvent("allowCustomizeChanged", p_enabled); } +void EmscriptenCallbacks::OnConnectionStatusChanged(int p_status) +{ + // clang-format off + MAIN_THREAD_EM_ASM({ + var canvas = Module.canvas; + if (canvas) { + canvas.dispatchEvent(new CustomEvent('connectionStatusChanged', { + detail: { status: $0 } + })); + } + }, p_status); + // clang-format on +} + } // namespace Multiplayer #endif // __EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp b/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp index 0b55dea8..41f33388 100644 --- a/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp @@ -44,8 +44,6 @@ void WebSocketTransport::Connect(const char* p_roomId) var connPtr = $1; var discPtr = $2; var everConnPtr = $3; - var exitRoomFull = $4; - var exitConnLost = $5; var socketId = Module._mpNextSocketId++; Module._mpMessageQueues[socketId] = []; @@ -66,10 +64,8 @@ void WebSocketTransport::Connect(const char* p_roomId) }; ws.onclose = function() { - var wasEverConnected = Atomics.load(HEAP32, everConnPtr >> 2); Atomics.store(HEAP32, connPtr >> 2, 0); Atomics.store(HEAP32, discPtr >> 2, 1); - Module._exitCode = wasEverConnected ? exitConnLost : exitRoomFull; }; ws.onerror = function() { @@ -83,7 +79,7 @@ void WebSocketTransport::Connect(const char* p_roomId) } return socketId; - }, url.c_str(), &m_connectedFlag, &m_disconnectedFlag, &m_wasEverConnected, EXIT_ROOM_FULL, EXIT_CONNECTION_LOST); + }, url.c_str(), &m_connectedFlag, &m_disconnectedFlag, &m_wasEverConnected); // clang-format on if (m_socketId <= 0) { diff --git a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp index 2007345d..75059592 100644 --- a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp +++ b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp @@ -32,6 +32,26 @@ void NativeCallbacks::OnAllowCustomizeChanged(bool p_enabled) SDL_Log("[Multiplayer] Allow customization: %s", p_enabled ? "enabled" : "disabled"); } +void NativeCallbacks::OnConnectionStatusChanged(int p_status) +{ + const char* statusStr = "unknown"; + switch (p_status) { + case CONNECTION_STATUS_CONNECTED: + statusStr = "connected"; + break; + case CONNECTION_STATUS_RECONNECTING: + statusStr = "reconnecting"; + break; + case CONNECTION_STATUS_FAILED: + statusStr = "failed"; + break; + case CONNECTION_STATUS_REJECTED: + statusStr = "rejected (room full)"; + break; + } + SDL_Log("[Multiplayer] Connection status: %s", statusStr); +} + } // namespace Multiplayer #endif // !__EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/worldstatesync.cpp b/extensions/src/multiplayer/worldstatesync.cpp index d16c08b1..2ac22ff2 100644 --- a/extensions/src/multiplayer/worldstatesync.cpp +++ b/extensions/src/multiplayer/worldstatesync.cpp @@ -66,6 +66,15 @@ void WorldStateSync::ApplySkyLightState(const char* p_skyColor, int p_lightPos) VariableTable()->SetVariable("lightposition", buf); } +void WorldStateSync::ResetForReconnect() +{ + m_localPeerId = 0; + m_sequence = 0; + m_isHost = false; + m_snapshotRequested = false; + m_pendingWorldEvents.clear(); +} + void WorldStateSync::OnHostChanged() { if (!m_isHost) {