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; }