mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-02 02:23:56 +00:00
Add WebSocket reconnection with exponential backoff
Automatically reconnect when the WebSocket connection is lost (e.g. phone sleep, alt-tab, network blip) instead of exiting the game. - Add reconnection state machine (CONNECTED → RECONNECTING → CONNECTED or DISCONNECTED) with exponential backoff (1s→30s cap, 10 max attempts) - Add OnConnectionStatusChanged callback (connected/reconnecting/failed/ rejected) to PlatformCallbacks, with Emscripten CustomEvent dispatch and native SDL_Log implementations - Add WorldStateSync::ResetForReconnect() to clear session state - Only exit the game on room-full rejection (WasRejected); connection loss is handled internally by the state machine - Rename WasDisconnected→WasRejected, CheckDisconnected→CheckRejected, IsMultiplayerDisconnected→IsMultiplayerRejected through the full call chain for accurate naming - Remove Module._exitCode from JS onclose; use C++ callback + sessionStorage for room-full signaling instead - Clean up EXIT_CONNECTION_LOST constant (obsolete) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
176aef1d90
commit
982957ee5e
@ -1300,7 +1300,7 @@ inline bool IsleApp::Tick()
|
||||
}
|
||||
|
||||
#ifdef EXTENSIONS
|
||||
if (Extensions::IsMultiplayerDisconnected()) {
|
||||
if (Extensions::IsMultiplayerRejected()) {
|
||||
g_closed = TRUE;
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<MultiplayerExt>::Call(MP::CheckDisconnected).value_or(FALSE);
|
||||
return Extension<MultiplayerExt>::Call(MP::CheckRejected).value_or(FALSE);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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__
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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__
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user