diff --git a/CHANGELOG.md b/CHANGELOG.md
index 108cf13..9d2c29a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,11 @@ Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d
- Admin-ITEMS: Werkzeuge und Laden-Artikel (Ratschenkasten, Batterie, Motoröl, Kühlmittel, Bremsflüssigkeit ...)
- Nach dem Trennen (Kick, Ban, Shutdown, Timeout ...) zeigt das Hauptmenü den Grund
- `reset-world` / `reload-save` auf dem Server: alle Spieler bekommen eine Meldung und verbinden sich automatisch neu
+- **Server holt sich den aktuellen Spielstand vom Spiel** (beim Beitreten und alle 5 Minuten): neue Spieler bekommen
+ exakt die aktuelle Welt – Schrauben fest, Teile dran – statt nachgespielter Aktionen; Backups der letzten 5 Stände
+- Feuer, Rauch, Dampf und Funken an Fahrzeugen werden synchronisiert, auch bei geparkten Autos
+- Kick-, Ban-, Reset- und Shutdown-Meldungen kommen zuverlässig an (Server trennt sauber)
+- Status-Punkt gelb, bis man wirklich verbunden ist
**Behoben**
- Auto fiel beim Fahren eines anderen Spielers auseinander (angebaute Teile rissen an ihren Gelenken ab)
diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs
index 7149f93..9e9c533 100644
--- a/MultiplayerManager.cs
+++ b/MultiplayerManager.cs
@@ -1134,6 +1134,11 @@ namespace MSCO
break;
}
+ case MsgType.SaveRequest:
+ // Server möchte den aktuellen Spielstand (für neue Spieler / zum Sichern)
+ StartCoroutine(UploadSave(r.ReadInt32()));
+ break;
+
case MsgType.Reconnect:
{
// Server hat die Welt zurückgesetzt o.ä.: Meldung zeigen, ins Menü, dann automatisch neu verbinden
@@ -1445,7 +1450,7 @@ namespace MSCO
}
try { objects.Update(localPlayer, Time.deltaTime); }
catch (System.Exception e) { ReportError("object sync", e); }
- try { vehicleFx.Update(objects, SendGame, Time.deltaTime); }
+ try { vehicleFx.Update(objects, SendGame, Time.deltaTime, IsWorldAuthority); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); }
catch (System.Exception e) { ReportError("time/weather sync", e); }
@@ -1627,6 +1632,46 @@ namespace MSCO
public Quaternion Rotation;
}
+ ///
+ /// Im Spiel speichern (wie auf der Toilette) und den Spielstand an den Server schicken. So bekommen neue Spieler
+ /// genau die aktuelle Welt - Schrauben, Teile, alles - statt eines alten Stands mit nachgespielten Aktionen.
+ /// Der eigene Spielstand ist davon nicht betroffen: er liegt gesichert im Backup und kommt im Hauptmenü zurück.
+ ///
+ System.Collections.IEnumerator UploadSave(int requestId)
+ {
+ if (!MenuHelper.InGame || !dedicated || session == null) yield break;
+ PlayMakerFSM.BroadcastEvent("SAVEGAME");
+ float until = Time.realtimeSinceStartup + 3f; // das Spiel schreibt die Dateien über mehrere Frames
+ while (Time.realtimeSinceStartup < until) yield return null;
+ if (session == null) yield break;
+
+ List> files;
+ try { files = SaveSync.ReadLocalSave(); }
+ catch (System.Exception e) { MscoLog.Warning("Could not read the save game for the server: " + e.Message); yield break; }
+
+ const int Chunk = 256 * 1024; // unter der Paketgrenze des Servers
+ foreach (KeyValuePair f in files)
+ {
+ for (int offset = 0; offset < f.Value.Length || offset == 0; offset += Chunk)
+ {
+ if (session == null) yield break;
+ int start = offset, length = Mathf.Min(Chunk, f.Value.Length - offset);
+ string name = f.Key;
+ byte[] data = f.Value;
+ session.SendTo(NetSession.HostId, MsgType.SaveUploadPart, NetSession.Build(w =>
+ {
+ w.Write(requestId); w.Write(name); w.Write(data.Length); w.Write(start); w.Write(length);
+ w.Write(data, start, length);
+ }));
+ yield return null;
+ if (data.Length == 0) break;
+ }
+ }
+ int count = files.Count;
+ session.SendTo(NetSession.HostId, MsgType.SaveUploadDone, NetSession.Build(w => { w.Write(requestId); w.Write(count); }));
+ MscoLog.Info("Sent the current save game to the server (" + count + " files)");
+ }
+
/// Sobald die erste Position des Hosts da ist: seitlich neben ihn stellen.
void TryTeleportToHost()
{
diff --git a/NetSession.cs b/NetSession.cs
index df6b33e..2852425 100644
--- a/NetSession.cs
+++ b/NetSession.cs
@@ -41,7 +41,10 @@ namespace MSCO
Teleport = 30, // Admin holt einen Spieler zu sich: 3 float Position
Pong = 31, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen)
SpawnItem = 32, // Admin spawnt ein Item: string Item-Name, 3 float Position, 4 float Drehung
- Reconnect = 33 // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt)
+ Reconnect = 33, // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt)
+ SaveRequest = 34, // Server -> Client: int Anfrage; im Spiel speichern und den Spielstand hochladen
+ SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes
+ SaveUploadDone = 36 // Client -> Server: int Anfrage, int Anzahl Dateien
}
public struct Message
diff --git a/ObjectSync.cs b/ObjectSync.cs
index 523dc9b..41e405c 100644
--- a/ObjectSync.cs
+++ b/ObjectSync.cs
@@ -168,6 +168,17 @@ namespace MSCO
return list;
}
+ /// Fahrzeuge, die gerade niemand besitzt (geparkt).
+ public List FreeVehicles()
+ {
+ var list = new List();
+ foreach (SyncedBody b in byId.Values)
+ if (b.IsVehicle && b.OwnerId == SyncedBody.NoOwner && b.Rb != null) list.Add(b);
+ return list;
+ }
+
+ public bool IsLocalBody(SyncedBody b) { return IsLocal(b); }
+
/// Fahrzeuge, die der lokale Spieler gerade besitzt (fährt/schiebt).
public List LocalVehicles()
{
diff --git a/VehicleFx.cs b/VehicleFx.cs
index 436a090..c5a204f 100644
--- a/VehicleFx.cs
+++ b/VehicleFx.cs
@@ -40,6 +40,7 @@ namespace MSCO
public readonly Dictionary Lights = new Dictionary();
public readonly Dictionary Audio = new Dictionary();
public readonly Dictionary Dials = new Dictionary();
+ public readonly Dictionary Particles = new Dictionary();
}
class Snapshot
@@ -48,6 +49,7 @@ namespace MSCO
public readonly Dictionary Lights = new Dictionary();
public readonly Dictionary Audio = new Dictionary();
public readonly Dictionary Dials = new Dictionary();
+ public readonly Dictionary Particles = new Dictionary();
}
readonly Dictionary components = new Dictionary();
@@ -62,13 +64,17 @@ namespace MSCO
// ---------------------------------------------------------------- Senden
- public void Update(ObjectSync objects, Action send, float dt)
+ public void Update(ObjectSync objects, Action send, float dt, bool worldAuthority)
{
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
- foreach (SyncedBody b in objects.LocalVehicles())
+ // Eigene Fahrzeuge (fahren/schieben) und - wenn wir die Welt vorgeben - auch freie, geparkte Fahrzeuge:
+ // sonst würde z.B. ein brennendes, abgestelltes Auto nur bei einem Spieler brennen
+ List vehicles = objects.LocalVehicles();
+ if (worldAuthority) vehicles.AddRange(objects.FreeVehicles());
+ foreach (SyncedBody b in vehicles)
{
Components c = GetComponents(b);
if (c == null) continue;
@@ -104,6 +110,16 @@ namespace MSCO
w.Write(d.Key);
w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
}
+ // Partikel-Effekte (Feuer, Rauch, Dampf, Funken): läuft / läuft nicht
+ int particleCount = 0;
+ foreach (ParticleSystem ps in c.Particles.Values) if (ps != null) particleCount++;
+ w.Write(particleCount);
+ foreach (KeyValuePair ps in c.Particles)
+ {
+ if (ps.Value == null) continue;
+ w.Write(ps.Key);
+ w.Write(ps.Value.gameObject.activeInHierarchy && ps.Value.isPlaying);
+ }
}));
}
}
@@ -132,6 +148,11 @@ namespace MSCO
if (!float.IsNaN(q.x) && !float.IsNaN(q.w)) s.Dials[key] = q;
}
}
+ if (r.BaseStream.Length - r.BaseStream.Position >= 4)
+ {
+ int particles = r.ReadInt32();
+ for (int i = 0; i < particles; i++) s.Particles[r.ReadInt32()] = r.ReadBoolean();
+ }
incoming[id] = s;
}
@@ -142,7 +163,7 @@ namespace MSCO
{
if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) continue;
SyncedBody b = objects.Get(kv.Key);
- if (b == null || !objects.IsRemoteOwnedBody(b)) continue;
+ if (b == null || objects.IsLocalBody(b)) continue; // eigene Fahrzeuge simulieren wir selbst
Components c = GetComponents(b);
if (c == null) continue;
@@ -156,6 +177,11 @@ namespace MSCO
AudioSource src;
if (c.Audio.TryGetValue(a.Key, out src) && src != null) SetAudio(src, a.Value);
}
+ foreach (KeyValuePair p in kv.Value.Particles)
+ {
+ ParticleSystem ps;
+ if (c.Particles.TryGetValue(p.Key, out ps) && ps != null) SetParticles(ps, p.Value, c.Root);
+ }
float t = Mathf.Clamp01(Time.deltaTime * DialSmoothing);
foreach (KeyValuePair d in kv.Value.Dials)
{
@@ -179,6 +205,18 @@ namespace MSCO
if (!t.gameObject.activeSelf) t.gameObject.SetActive(true);
}
+ static void SetParticles(ParticleSystem ps, bool playing, Transform root)
+ {
+ if (playing)
+ {
+ // Effekte hängen oft an einem abgeschalteten Objekt (z.B. "Fire"), das die Spiel-Logik aktiviert
+ for (Transform t = ps.transform; t != null && t != root; t = t.parent)
+ if (!t.gameObject.activeSelf) t.gameObject.SetActive(true);
+ if (!ps.isPlaying) ps.Play();
+ }
+ else if (ps.isPlaying) ps.Stop();
+ }
+
static void SetAudio(AudioSource src, AudioState state)
{
if (!src.gameObject.activeInHierarchy) return;
@@ -206,6 +244,9 @@ namespace MSCO
seen.Clear();
foreach (Transform t in b.Rb.GetComponentsInChildren(true))
if (IsDial(t.name)) c.Dials[Key(t, c.Root, seen)] = t;
+ seen.Clear();
+ foreach (ParticleSystem ps in b.Rb.GetComponentsInChildren(true))
+ c.Particles[Key(ps.transform, c.Root, seen)] = ps;
components[b.Id] = c;
return c;
}