Disconnect/kick prompt with reason, reconnect after world reset with retries; stop mass 'consumed' deletes on scene reload

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:31:40 +02:00
co-authored by Claude Opus 5.5
parent 90c1af4513
commit 31e630dccb
4 changed files with 137 additions and 38 deletions
+10
View File
@@ -14,6 +14,16 @@ Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d
- **Verkehr**: NPC-Autos fahren bei allen an derselben Stelle (der erste Spieler simuliert, die anderen folgen)
- `vehicles` in der F7-Konsole zeigt, wie viele Karosserie-Meshes, Job-Werte und Verkehrsautos synchronisiert werden
**Trennen & Neu-Verbinden**
- Gekickt, gebannt, Server beendet, Verbindung verloren: eigenes Fenster in der Bildmitte mit **Grund** und OK
(im Spiel wird der Grund vorher 2,5 s groß angezeigt)
- `reset-world` / `reload-save`: Fenster „Reconnecting“ – verbindet automatisch neu, bis zu 5 Versuche (falls der
Server gerade neu startet), mit CANCEL-Knopf
**Behoben**
- Beim Neuladen der Welt wurden herumliegende Objekte als „verbraucht“ gemeldet und bei allen gelöscht. Jetzt werden
nur einzelne echte Items gemeldet, Fahrzeuge/Teile nie gelöscht, und der Server nimmt höchstens 20 pro Minute an
**Leistung**
- Mod: herumliegende Objekte werden nur noch jedes 4. Frame geprüft (eigene/gehaltene/gefahrene weiterhin jedes Frame),
weniger Speicher-Allokationen pro Frame, Gelenke fremd gefahrener Autos seltener geprüft
+51 -25
View File
@@ -73,7 +73,10 @@ namespace MSCO
bool pendingJoin = mp.PendingJoinHost != null && !mp.IsInSession;
bool versionWarning = mp.PendingVersionWarning != null && !mp.IsInSession;
bool disconnected = mp.DisconnectNotice != null && !mp.IsInSession;
if (pendingJoin || versionWarning || disconnected) minimized = false; // JOIN-Link / Warnung / Trennung: Fenster zeigen
// Getrennt / Neu-Verbinden: eigenes Fenster in der Bildmitte, alles andere gesperrt
if (disconnected) { DrawPrompt(mp, false); return; }
if (mp.IsReconnecting) { DrawPrompt(mp, true); return; }
if (pendingJoin || versionWarning) minimized = false; // JOIN-Link / Warnung: Fenster zeigen
if (minimized) { DrawMinimized(mp); return; }
var window = new Rect((Screen.width - Width) / 2f, (Screen.height - Height) / 2f, Width, Height);
@@ -85,10 +88,8 @@ namespace MSCO
var content = new Rect(window.x + SidebarWidth + 32, window.y + 28,
Width - SidebarWidth - 64, Height - StatusHeight - 52);
GUILayout.BeginArea(content);
if (disconnected) DrawDisconnectNotice(mp);
else if (versionWarning) DrawVersionWarning(mp);
if (versionWarning) DrawVersionWarning(mp);
else if (pendingJoin) DrawJoinConfirm(mp);
else if (mp.IsReconnecting) DrawReconnecting(mp);
else if (mp.IsInSession) DrawSession(mp);
else if (page == Page.P2P) DrawP2P(mp);
else DrawServerBrowser(mp);
@@ -486,29 +487,54 @@ namespace MSCO
GUILayout.EndHorizontal();
}
/// <summary>Nach dem Trennen (Kick, Ban, Shutdown, Timeout ...): Grund anzeigen.</summary>
void DrawDisconnectNotice(MultiplayerManager mp)
{
GUILayout.Label("Disconnected", titleStyle);
GUILayout.Space(8);
GUILayout.BeginVertical(rowBox);
GUILayout.Space(6);
GUILayout.Label("REASON", captionStyle);
GUILayout.Label(mp.DisconnectNotice, rowNameStyle);
GUILayout.Space(6);
GUILayout.EndVertical();
GUILayout.Space(8);
GUILayout.Label("Your own save game has been restored.", textStyle);
GUILayout.FlexibleSpace();
if (GUILayout.Button("OK", primaryStyle, GUILayout.Height(42), GUILayout.Width(170))) mp.ClearDisconnectNotice();
}
const float PromptWidth = 540f;
const float PromptHeight = 270f;
Texture2D texDim, texDanger;
GUIStyle promptTextStyle;
void DrawReconnecting(MultiplayerManager mp)
/// <summary>
/// Fenster in der Bildmitte über abgedunkeltem Hintergrund: nach dem Trennen (Kick, Ban, Shutdown, Timeout ...)
/// mit Grund und OK, bzw. beim automatischen Neu-Verbinden nach reset-world mit Versuch-Anzeige und CANCEL.
/// </summary>
void DrawPrompt(MultiplayerManager mp, bool reconnecting)
{
GUILayout.Label("Reconnecting", titleStyle);
GUILayout.Space(8);
GUILayout.Label(mp.ReconnectMessage, rowNameStyle);
GUILayout.Label("You will be connected to the server again in a moment.", textStyle);
if (texDim == null) texDim = Solid(new Color(0f, 0f, 0f, 0.6f));
if (texDanger == null) texDanger = Solid(Hex(0xe5, 0x48, 0x4d));
if (promptTextStyle == null)
{
promptTextStyle = new GUIStyle(rowNameStyle);
promptTextStyle.wordWrap = true;
promptTextStyle.fontSize = 15;
}
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), texDim);
var r = new Rect((Screen.width - PromptWidth) / 2f, (Screen.height - PromptHeight) / 2f, PromptWidth, PromptHeight);
ScreenRect = new Rect(0, 0, Screen.width, Screen.height); // Spielmenü dahinter komplett sperren
GUI.Box(r, GUIContent.none, windowBox);
string title = reconnecting ? "RECONNECTING" : mp.DisconnectTitle;
bool bad = !reconnecting && title != "DISCONNECTED";
GUI.DrawTexture(new Rect(r.x, r.y, r.width, 4), bad ? texDanger : texAccent);
float x = r.x + 32, w = r.width - 64, y = r.y + 28;
GUI.Label(new Rect(x, y, w, 34), title, titleStyle);
y += 50;
GUI.Label(new Rect(x, y, w, 20), "REASON", captionStyle);
if (reconnecting)
{
GUI.Label(new Rect(x, y + 22, w, 44), mp.ReconnectMessage ?? "", promptTextStyle);
string attempt = mp.ReconnectAttempt == 0
? "Connecting again in a moment..."
: "Attempt " + mp.ReconnectAttempt + " of " + mp.ReconnectMaxAttempts +
(string.IsNullOrEmpty(mp.Status) ? "" : " - " + mp.Status);
GUI.Label(new Rect(x, y + 74, w, 40), attempt, textStyle);
if (GUI.Button(new Rect(r.xMax - 32 - 170, r.yMax - 28 - 42, 170, 42), "CANCEL", secondaryStyle)) mp.CancelReconnect();
return;
}
GUI.Label(new Rect(x, y + 22, w, 60), mp.DisconnectNotice ?? "", promptTextStyle);
GUI.Label(new Rect(x, y + 88, w, 20), "Your own save game has been restored.", textStyle);
if (GUI.Button(new Rect(r.xMax - 32 - 170, r.yMax - 28 - 42, 170, 42), "OK", primaryStyle)) mp.ClearDisconnectNotice();
}
void DrawSession(MultiplayerManager mp)
+53 -7
View File
@@ -594,6 +594,9 @@ namespace MSCO
string centerMessage; // große Meldung in der Bildmitte
float centerMessageUntil;
float reconnectAt;
int reconnectAttempts;
const int MaxReconnectAttempts = 5;
const float ReconnectRetrySeconds = 6f;
void ConnectToServer(string host, int port, string displayName, int maxPlayers)
{
@@ -817,14 +820,22 @@ namespace MSCO
steam.Tick();
// Nach reset-world o.ä.: im Hauptmenü automatisch wieder mit demselben Server verbinden
if (reconnectMessage != null && session == null && MenuHelper.InMenu && Time.realtimeSinceStartup >= reconnectAt)
// Nach reset-world o.ä.: im Hauptmenü automatisch wieder mit demselben Server verbinden. Mehrere Versuche -
// der Server kann nach einem Neustart noch ein paar Sekunden brauchen.
if (reconnectMessage != null && session == null && MenuHelper.InMenu && !checkingServer
&& Time.realtimeSinceStartup >= reconnectAt)
{
string message = reconnectMessage;
reconnectMessage = null;
if (lastServerHost != null)
if (lastServerHost == null || reconnectAttempts >= MaxReconnectAttempts)
{
SetStatus(message);
DisconnectNotice = "Could not reconnect to the server after " + reconnectAttempts + " attempts. " +
"Try again from the server browser.";
reconnectMessage = null;
reconnectAttempts = 0;
}
else
{
reconnectAttempts++;
reconnectAt = Time.realtimeSinceStartup + ReconnectRetrySeconds;
JoinServer(lastServerHost, lastServerPort, lastServerName, lastServerMax);
}
}
@@ -909,6 +920,30 @@ namespace MSCO
public string DisconnectNotice { get; private set; }
public void ClearDisconnectNotice() { DisconnectNotice = null; }
public bool IsReconnecting { get { return reconnectMessage != null && session == null; } }
public int ReconnectAttempt { get { return reconnectAttempts; } }
public int ReconnectMaxAttempts { get { return MaxReconnectAttempts; } }
/// <summary>Automatisches Neu-Verbinden abbrechen (Knopf im Reconnect-Fenster).</summary>
public void CancelReconnect()
{
reconnectMessage = null;
reconnectAttempts = 0;
SetStatus("Reconnect cancelled");
}
/// <summary>Überschrift für das Trennungs-Fenster.</summary>
public string DisconnectTitle
{
get
{
string r = (DisconnectNotice ?? "").ToLower();
if (r.Contains("banned")) return "BANNED";
if (r.Contains("kick")) return "KICKED";
if (r.Contains("shutting down") || r.Contains("server stopped") || r.Contains("shut down")) return "SERVER CLOSED";
if (r.Contains("could not reconnect")) return "RECONNECT FAILED";
return "DISCONNECTED";
}
}
bool worldSynced; // Server: Weltzustand angewendet
@@ -1120,6 +1155,8 @@ namespace MSCO
AddPlayer(id, r.ReadString(), id == localId);
}
handshakeDone = true;
reconnectMessage = null; // (wieder) verbunden
reconnectAttempts = 0;
SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start...");
break;
}
@@ -1127,8 +1164,17 @@ namespace MSCO
case MsgType.Reject:
{
string why = r.ReadString();
serverReason = why; // für den Hinweis im Hauptmenü (Kick, Ban, Server voll, Shutdown ...)
serverReason = why; // für das Fenster im Hauptmenü (Kick, Ban, Server voll, Shutdown ...)
if (why.ToLower().Contains("banned")) reconnectMessage = null; // gebannt: nicht weiter versuchen
session.Close((dedicated || connectingToServer ? "Rejected by server: " : "Rejected by host: ") + why);
if (MenuHelper.InGame)
{
// Im Spiel: Grund erst groß anzeigen, dann ins Hauptmenü (dort kommt das Fenster mit OK)
centerMessage = why;
centerMessageUntil = Time.realtimeSinceStartup + 3f;
reconnectHoldUntil = Time.realtimeSinceStartup + 2.5f;
return;
}
OnSessionEnded(session.CloseReason);
break;
}
+23 -6
View File
@@ -16,6 +16,7 @@ namespace MSCO
public int Id;
public string Path;
public Rigidbody Rb;
public string Name;
public GameObject Go; // zum Unterscheiden: nur Rigidbody weg (angebaut) oder ganzes Objekt weg (verbraucht)
public Vector3 LastPos;
public bool IsVehicle;
@@ -101,6 +102,23 @@ namespace MSCO
public void MarkNetworkCreated(GameObject go) { if (go != null) networkCreated.Add(go); }
/// <summary>
/// Verschwundene Items melden (gegessen, getrunken, verbraucht) - nur wenn das ganze Objekt weg ist (nicht nur der
/// Rigidbody wie beim Anbauen), es ein Item ist und es bei uns passiert ist. Verschwinden viele auf einmal, wird
/// gerade die Szene entladen (Neu laden, Hauptmenü) - dann NICHTS melden, sonst löscht man bei allen die Welt.
/// </summary>
void ReportConsumed(Transform player)
{
if (ItemDestroyed == null || removeList.Count == 0) return;
if (player == null || removeList.Count > 2 || Time.time - loadTime < 30f) return;
foreach (SyncedBody b in removeList)
{
if (b.Go != null || b.IsVehicle || b.Name == null || !b.Name.EndsWith("(itemx)")) continue;
if (IsLocal(b) || (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
ItemDestroyed(b);
}
}
/// <summary>Gibt es dieses Objekt bei uns schon an dieser Stelle? (dann nicht nochmal erzeugen)</summary>
public bool HasNear(int id, Vector3 pos, float range)
{
@@ -113,6 +131,8 @@ namespace MSCO
{
SyncedBody b;
if (!byId.TryGetValue(id, out b)) return false;
// Nur echte Items (Essen, Getränke ...) - nie Fahrzeuge oder Teile, egal was ein anderer meldet
if (b.IsVehicle || b.Name == null || !b.Name.EndsWith("(itemx)")) return false;
if (b.Go != null) UnityEngine.Object.Destroy(b.Go);
Unregister(b);
return true;
@@ -307,11 +327,6 @@ namespace MSCO
if (b.OwnerId == SyncedBody.NoOwner && ((b.Id ^ frame) & 3) != 0 && b != held && b != driven) continue;
if (b.Rb == null)
{
// Ganzes Objekt weg (gegessen, getrunken, verbraucht) - nicht nur der Rigidbody (Teil angebaut).
// Nur melden, wenn es bei uns passiert ist: wir besaßen es oder standen daneben.
if (b.Go == null && !b.IsVehicle && ItemDestroyed != null
&& (IsLocal(b) || (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange))
ItemDestroyed(b);
removeList.Add(b);
continue;
}
@@ -350,6 +365,7 @@ namespace MSCO
else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b);
}
}
ReportConsumed(player);
foreach (SyncedBody b in removeList) Unregister(b);
Held = held != null && held.Rb != null && IsLocal(held) ? held : null;
@@ -895,6 +911,7 @@ namespace MSCO
Path = path,
Rb = c.Key,
Go = c.Key.gameObject,
Name = c.Key.gameObject.name,
LastPos = c.Key.position,
IsVehicle = c.Value,
RootName = c.Key.transform.root.name
@@ -906,7 +923,7 @@ namespace MSCO
// Neues Item nach dem ersten Suchlauf, bei uns in der Nähe und nicht übers Netz entstanden:
// wahrscheinlich gekauft / ausgepackt / geliefert - den anderen melden
bool isItem = !b.IsVehicle && b.Go.name.EndsWith("(itemx)");
if (ScanComplete && isItem && ItemAppeared != null && !networkCreated.Contains(b.Go)
if (ScanComplete && isItem && ItemAppeared != null && !networkCreated.Contains(b.Go) && Time.time - loadTime > 30f
&& (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
ItemAppeared(b);
}