Server gets the current save game from a player in the game (on join and every few minutes) instead of replaying actions; vehicle fire/smoke synced, also for parked cars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 11:15:18 +02:00
co-authored by Claude Opus 5.5
parent bdaedcc1e8
commit b3405d3752
5 changed files with 110 additions and 5 deletions
+5
View File
@@ -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)
+46 -1
View File
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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<KeyValuePair<string, byte[]>> 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<string, byte[]> 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)");
}
/// <summary>Sobald die erste Position des Hosts da ist: seitlich neben ihn stellen.</summary>
void TryTeleportToHost()
{
+4 -1
View File
@@ -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
+11
View File
@@ -168,6 +168,17 @@ namespace MSCO
return list;
}
/// <summary>Fahrzeuge, die gerade niemand besitzt (geparkt).</summary>
public List<SyncedBody> FreeVehicles()
{
var list = new List<SyncedBody>();
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); }
/// <summary>Fahrzeuge, die der lokale Spieler gerade besitzt (fährt/schiebt).</summary>
public List<SyncedBody> LocalVehicles()
{
+44 -3
View File
@@ -40,6 +40,7 @@ namespace MSCO
public readonly Dictionary<int, Light> Lights = new Dictionary<int, Light>();
public readonly Dictionary<int, AudioSource> Audio = new Dictionary<int, AudioSource>();
public readonly Dictionary<int, Transform> Dials = new Dictionary<int, Transform>();
public readonly Dictionary<int, ParticleSystem> Particles = new Dictionary<int, ParticleSystem>();
}
class Snapshot
@@ -48,6 +49,7 @@ namespace MSCO
public readonly Dictionary<int, bool> Lights = new Dictionary<int, bool>();
public readonly Dictionary<int, AudioState> Audio = new Dictionary<int, AudioState>();
public readonly Dictionary<int, Quaternion> Dials = new Dictionary<int, Quaternion>();
public readonly Dictionary<int, bool> Particles = new Dictionary<int, bool>();
}
readonly Dictionary<int, Components> components = new Dictionary<int, Components>();
@@ -62,13 +64,17 @@ namespace MSCO
// ---------------------------------------------------------------- Senden
public void Update(ObjectSync objects, Action<MsgType, byte[]> send, float dt)
public void Update(ObjectSync objects, Action<MsgType, byte[]> 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<SyncedBody> 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<int, ParticleSystem> 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<int, bool> 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<int, Quaternion> 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<Transform>(true))
if (IsDial(t.name)) c.Dials[Key(t, c.Root, seen)] = t;
seen.Clear();
foreach (ParticleSystem ps in b.Rb.GetComponentsInChildren<ParticleSystem>(true))
c.Particles[Key(ps.transform, c.Root, seen)] = ps;
components[b.Id] = c;
return c;
}